From 4613f0d81493a381d2f0c0f4404e7ceaf7768aff Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Tue, 18 Aug 2026 03:36:01 +0000 Subject: [PATCH 1/2] BACKPORT-CONFLICT --- cmd/seid/cmd/legacy_config_fuzz_test.go | 1261 ++++++++++++++ sei-cosmos/server/config/config.go | 25 +- sei-cosmos/server/config/config_fuzz_test.go | 1528 +++++++++++++++++ sei-cosmos/server/config/config_test.go | 42 + .../config/testdata/base_config.keys.golden | 14 + .../config/testdata/server_config.golden | 153 ++ sei-cosmos/server/config/toml.go | 4 + sei-cosmos/server/start.go | 14 +- sei-tendermint/internal/blocksync/reactor.go | 37 + .../internal/blocksync/reactor_test.go | 48 + sei-tendermint/internal/consensus/state.go | 75 +- .../internal/consensus/state_test.go | 43 + sei-tendermint/node/freeze_test.go | 41 + sei-tendermint/node/node.go | 106 ++ sei-tendermint/node/public.go | 34 +- 15 files changed, 3405 insertions(+), 20 deletions(-) create mode 100644 cmd/seid/cmd/legacy_config_fuzz_test.go create mode 100644 sei-cosmos/server/config/config_fuzz_test.go create mode 100644 sei-cosmos/server/config/testdata/base_config.keys.golden create mode 100644 sei-cosmos/server/config/testdata/server_config.golden create mode 100644 sei-tendermint/node/freeze_test.go diff --git a/cmd/seid/cmd/legacy_config_fuzz_test.go b/cmd/seid/cmd/legacy_config_fuzz_test.go new file mode 100644 index 0000000000..9c8ad68efb --- /dev/null +++ b/cmd/seid/cmd/legacy_config_fuzz_test.go @@ -0,0 +1,1261 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "maps" + "os" + "reflect" + "slices" + "strings" + "testing" + + "go.opentelemetry.io/otel/sdk/trace" + + "github.com/sei-protocol/sei-chain/cmd/seid/cmd/configmanager" + "github.com/sei-protocol/sei-chain/sei-cosmos/client/flags" + "github.com/sei-protocol/sei-chain/sei-cosmos/server" + seidbconfig "github.com/sei-protocol/sei-chain/sei-db/config" + "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/memiavl" + tmcfg "github.com/sei-protocol/sei-chain/sei-tendermint/config" + wasmtypes "github.com/sei-protocol/sei-chain/sei-wasmd/x/wasm/types" + "github.com/sei-protocol/sei-chain/testutil/configtest" + "github.com/spf13/cast" + "github.com/spf13/cobra" +) + +// This file pins the legacy boot seam itself: LegacyConfigManager.Apply, which +// forwards verbatim to server.InterceptConfigsPreRunHandler. +// +// Apply is the whole legacy configuration path in one call. It resolves --home, +// creates or reads config/config.toml, unmarshals it into a tmcfg.Config, creates +// or merges config/app.toml into the same viper, binds every cobra flag, and +// leaves two channels behind for the rest of the boot: +// +// serverCtx.Config — the Tendermint config struct, populated by viper.Unmarshal +// serverCtx.Viper — the flat key/value map every appOpts.Get() call site reads +// +// Those two channels are the entire interface between configuration and a running +// node, which is what makes them the right thing to pin. A replacement manager is +// correct exactly insofar as it leaves the same two channels in the same state, +// and every target here states one property of that state precisely enough for a +// second implementation to be measured against it. +// +// Everything runs in a pinned environment (configtest.Isolate) against a fixture +// home. That is not tidiness: the path reads bare environment variables and $HOME, +// so an un-pinned environment makes the assertions mean different things on +// different machines. + +// applyResult is what one boot through the legacy manager leaves behind. +type applyResult struct { + ctx *server.Context + err error +} + +// applyLegacy boots one fixture home through LegacyConfigManager.Apply with the +// given explicit flags, and returns the resulting channels. +// +// The command is built fresh for every call, from the real server.StartCmd flag +// set and the real initAppConfig template, so the flag universe and the app.toml +// template are the node's own rather than a test's approximation. Setting a flag +// through cmd.Flags().Set marks it Changed, which is exactly how cobra represents +// "the operator passed this on the command line" — so the flag layer here is the +// real flag layer, not a viper override standing in for one. +// +// StartCmd's own PreRunE is deliberately not run. It layers more behavior on top +// (re-binding flags, fail-fast pruning validation, pinning chain-id from +// client.toml at override precedence) which belongs to separate manifest rows; +// this harness isolates Apply. +func applyLegacy(t *testing.T, home *configtest.Home, flagValues map[string]string) applyResult { + t.Helper() + cmd, serverCtx := newApplyCommand(t, home) + setFlags(t, cmd, flagValues) + return applyResult{ctx: serverCtx, err: applyThrough(cmd)} +} + +// newApplyCommand builds the command and server context Apply runs against, with +// --home already pointed at the fixture. It is separate from applyLegacy so a test +// that needs to inspect the flag set *after* Apply — the write-back in bindFlags +// mutates it — can hold onto the command. +func newApplyCommand(t *testing.T, home *configtest.Home) (*cobra.Command, *server.Context) { + t.Helper() + + cmd := server.StartCmd(nil, home.Root, []trace.TracerProviderOption{}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + if err := cmd.Flags().Set(flags.FlagHome, home.Root); err != nil { + t.Fatalf("set --home: %v", err) + } + + serverCtx := &server.Context{} + cmd.SetContext(context.WithValue(context.Background(), server.ServerContextKey, serverCtx)) + return cmd, serverCtx +} + +// setFlags applies flag values in sorted key order. +// +// Ranging a map directly would apply them in a different order per run. Every caller here +// sets flags that do not interact, so nothing depends on the order today, but a fuzz +// corpus is only useful if a failing entry reproduces: the first row whose flags interact, +// through cobra validation or one flag's Set reading another, would otherwise fail +// intermittently against the seed that found it. Sorting costs nothing and removes the +// class. +func setFlags(t *testing.T, cmd *cobra.Command, flagValues map[string]string) { + t.Helper() + for _, name := range slices.Sorted(maps.Keys(flagValues)) { + if err := cmd.Flags().Set(name, flagValues[name]); err != nil { + t.Fatalf("set --%s=%q: %v", name, flagValues[name], err) + } + } +} + +// applyThrough runs the legacy manager against a command from newApplyCommand, using +// the node's real template and config struct. +// +// It takes no server.Context: Apply reaches the one it populates through the command's own +// context, set in newApplyCommand, so a parameter here would only imply the context is +// threaded through this call. +func applyThrough(cmd *cobra.Command) error { + template, appConfig := initAppConfig() + return configmanager.LegacyConfigManager{}.Apply(cmd, template, appConfig) +} + +// setServerEnv sets the environment variable the server viper reads for a config +// key, deriving the name the way viper does from the running binary's basename. +func setServerEnv(t *testing.T, key, value string) { + t.Helper() + prefix, err := configtest.ServerEnvPrefix() + if err != nil { + t.Fatalf("resolve env prefix: %v", err) + } + name := configtest.ServerEnvKey(prefix, key) + if err := os.Setenv(name, value); err != nil { + t.Fatalf("set %s: %v", name, err) + } + t.Cleanup(func() { _ = os.Unsetenv(name) }) +} + +// tmKey is a Tendermint config key reachable from all four layers: it has a +// cobra flag, it lives in config.toml, it has an env spelling, and it has an +// in-code default. +type tmKey struct { + // Key is the dotted config.toml key, which for these rows is also the flag + // name and the basis of the env var. + Key string + // Path is the Dump path of the tmcfg.Config field it resolves into. + Path string + // Values are three distinct, individually-valid values for the key, used one + // per layer so the winning layer is identifiable from the result alone. + Values [3]string +} + +// tmKeys are the Tendermint rows the precedence target drives. Each carries three +// distinct legal values so that "which layer won" is readable directly off the +// resolved config. +var tmKeys = []tmKey{ + { + Key: "rpc.laddr", Path: "RPC.ListenAddress", + Values: [3]string{"tcp://127.0.0.1:26610", "tcp://127.0.0.1:26620", "tcp://127.0.0.1:26630"}, + }, + { + Key: "rpc.pprof-laddr", Path: "RPC.PprofListenAddress", + Values: [3]string{"localhost:6010", "localhost:6020", "localhost:6030"}, + }, + { + Key: "p2p.laddr", Path: "P2P.ListenAddress", + Values: [3]string{"tcp://0.0.0.0:26610", "tcp://0.0.0.0:26620", "tcp://0.0.0.0:26630"}, + }, + { + Key: "p2p.persistent-peers", Path: "P2P.PersistentPeers", + Values: [3]string{"a@1.1.1.1:26656", "b@2.2.2.2:26656", "c@3.3.3.3:26656"}, + }, + { + Key: "moniker", Path: "Moniker", + Values: [3]string{"from-file", "from-env", "from-flag"}, + }, +} + +// FuzzHashVaultDisabledUnsafeResolution pins the root-scope kill switch for the +// app-hash equivocation guard. +// +// Two things make it worth its own target. It is a bool whose safe value is the +// default, so an absent key must resolve false — setting it true removes +// equivocation protection with only a log banner. And it lives at TOML root scope, +// before any [section] header: nested under a section it parses as a different key +// and is silently ignored, which reads as "I disabled the guard" while the guard +// stays on, and would read the other way round if the scope were ever mishandled. +// The document is built from the fuzzer's choices rather than taken as free text, +// so the expected outcome follows from construction instead of being a second +// input the fuzzer can mutate out of agreement with the first. +func FuzzHashVaultDisabledUnsafeResolution(f *testing.F) { + f.Add(false, false, false) + f.Add(true, true, false) // root scope, true: the guard is off + f.Add(true, false, false) // root scope, false + f.Add(true, true, true) // nested under a section: silently ignored + f.Add(true, false, true) + + f.Fuzz(func(t *testing.T, present, value, underSection bool) { + configtest.Isolate(t) + home := configtest.NewHome(t) + + var doc strings.Builder + if present { + if underSection { + doc.WriteString("[p2p]\n") + } + fmt.Fprintf(&doc, "hash-vault-disabled-unsafe = %t\n", value) + } + if doc.Len() > 0 { + home.WriteConfigTOML(t, []byte(doc.String())) + } + + // Root scope is the only placement that resolves. Nested under a section the + // key becomes p2p.hash-vault-disabled-unsafe, which nothing reads. + wantDisabled := present && value && !underSection + + got := applyLegacy(t, home, nil) + if got.err != nil { + t.Fatalf("Apply must succeed on a well-formed config.toml, got %v", got.err) + } + if got.ctx.Config.HashVaultDisabledUnsafe != wantDisabled { + t.Fatalf("hash-vault-disabled-unsafe resolved to %v, want %v, from:\n%s", + got.ctx.Config.HashVaultDisabledUnsafe, wantDisabled, doc.String()) + } + }) +} + +// TestHashVaultDisabledUnsafeDefaultsToEnabledGuard states the default on its own, +// so the guard's safe value is pinned even if every seed above were removed. +func TestHashVaultDisabledUnsafeDefaultsToEnabledGuard(t *testing.T) { + configtest.Isolate(t) + got := applyLegacy(t, configtest.NewHome(t), nil) + if got.err != nil { + t.Fatalf("Apply: %v", got.err) + } + if got.ctx.Config.HashVaultDisabledUnsafe { + t.Fatal("an empty home must leave the app-hash equivocation guard enabled") + } +} + +// FuzzApplyPrecedenceTendermint pins the resolution order for Tendermint config: +// flag beats environment beats config.toml beats the in-code default. +// +// The three layers carry three different legal values, so the assertion reads the +// winner straight off serverCtx.Config rather than inferring it. The fuzzer's job +// is to enumerate the presence combinations across every row, including the ones +// nobody writes a hand test for — env set but file absent, flag set with neither, +// all three set at once. +// +// This is the ordering the whole four-layer model in the ConfigManager design +// rests on, and it is currently an emergent property of viper's precedence +// interacting with bindFlags' write-back rather than anything stated in one place. +// Pinning it is what makes it a contract. +func FuzzApplyPrecedenceTendermint(f *testing.F) { + // Every row against every presence combination, generated rather than listed. A plain go test + // run replays seeds and nothing else, and the row index is reduced modulo len(tmKeys), so a + // hand-written list leaves any row it omits unexercised. It did: the list here named rows 0, 3 + // and 4, so rpc.pprof-laddr and p2p.laddr never ran outside a -fuzz session. Generating the + // product means a row added later is driven without anyone remembering to seed it. + // + // It is not free. Each seed runs Isolate and a full Apply that materialises config files, at + // roughly 1.75ms, so this target went from about 0.02s at ten seeds to 0.07s at forty. Against a + // package that runs in about a second that is the trade, and it buys two of the five rows being + // exercised at all. + for row := range len(tmKeys) { + for _, layers := range [][3]bool{ + {false, false, false}, // no layer supplies a value, so the in-code default stands + {true, false, false}, // config.toml alone + {false, true, false}, // environment alone + {false, false, true}, // flag alone + {true, true, false}, // environment beats the file + {true, false, true}, // flag beats the file + {false, true, true}, // flag beats the environment + {true, true, true}, // all three, so the flag must win + } { + f.Add(uint(row), layers[0], layers[1], layers[2]) + } + } + + f.Fuzz(func(t *testing.T, keyIdx uint, inFile, inEnv, inFlag bool) { + configtest.Isolate(t) + row := tmKeys[keyIdx%uint(len(tmKeys))] + home := configtest.NewHome(t) + + // A dotted TOML key is a table path, so one line per key is enough to + // place a value in any section without rendering the section header. + if inFile { + home.WriteConfigTOML(t, []byte(fmt.Sprintf("%s = %q\n", row.Key, row.Values[0]))) + } + if inEnv { + setServerEnv(t, row.Key, row.Values[1]) + } + flagValues := map[string]string{} + if inFlag { + flagValues[row.Key] = row.Values[2] + } + + got := applyLegacy(t, home, flagValues) + if got.err != nil { + t.Fatalf("%s: Apply must succeed with legal values in every layer, got %v", row.Key, got.err) + } + + want := "" + switch { + case inFlag: + want = row.Values[2] + case inEnv: + want = row.Values[1] + case inFile: + want = row.Values[0] + } + + leaf, ok := configtest.LeafAt(configtest.Dump(*got.ctx.Config), row.Path) + if !ok { + t.Fatalf("%s claims to resolve into %q, which is not in the resolved Tendermint config", row.Key, row.Path) + } + if want == "" { + // No layer supplied a value, so the in-code default stands. The default + // itself is not asserted — moniker's is the hostname, which is + // machine-dependent — only that no absent layer leaked a test value in. + for _, v := range row.Values { + if leaf == configtest.DumpAt(row.Path, v) { + t.Fatalf("%s resolved to %s with no layer setting it", row.Key, leaf) + } + } + return + } + if wantLeaf := configtest.DumpAt(row.Path, want); leaf != wantLeaf { + t.Fatalf("%s did not resolve to the highest present layer\n got: %s\nwant: %s\n"+ + "layers: file=%v env=%v flag=%v", row.Key, leaf, wantLeaf, inFile, inEnv, inFlag) + } + }) +} + +// appKey is an app.toml key that also has a cobra flag, resolved through +// serverCtx.Viper rather than through the Tendermint struct. +type appKey struct { + Key string + Values [3]string + // Numeric marks a key whose app.toml spelling is an unquoted TOML integer, so + // the file layer carries a typed scalar rather than a quoted string. + Numeric bool + // WantGoType is the Go type the value has once it reaches appOpts.Get, + // whichever layer supplied it. See FuzzApplyPrecedenceApp for why it is a + // property of the flag's declared type and not of the winning layer. + WantGoType string +} + +var appKeys = []appKey{ + {Key: "pruning", Values: [3]string{"nothing", "everything", "default"}, WantGoType: "string"}, + {Key: "minimum-gas-prices", Values: [3]string{"0.01usei", "0.02usei", "0.03usei"}, WantGoType: "string"}, + {Key: "halt-height", Values: [3]string{"100", "200", "300"}, Numeric: true, WantGoType: "string"}, + {Key: "min-retain-blocks", Values: [3]string{"1000", "2000", "3000"}, Numeric: true, WantGoType: "string"}, + {Key: "grpc.address", Values: [3]string{"127.0.0.1:9010", "127.0.0.1:9020", "127.0.0.1:9030"}, WantGoType: "string"}, + {Key: "state-sync.snapshot-interval", Values: [3]string{"100", "200", "300"}, Numeric: true, WantGoType: "string"}, + // concurrency-workers is registered as an Int flag, which is one of the few + // types viper converts rather than passing through as text. + {Key: "concurrency-workers", Values: [3]string{"4", "8", "16"}, Numeric: true, WantGoType: "int"}, + {Key: "freeze-height", Values: [3]string{"100", "200", "300"}, Numeric: true, WantGoType: "string"}, +} + +// FuzzApplyPrecedenceApp pins the same ordering on the other channel. App +// configuration never becomes a struct during Apply — it stays a flat viper map +// that app.New reads key by key through appOpts.Get — so the assertion is on +// serverCtx.Viper.Get and the comparison is on the rendered value, which keeps the +// resolved *type* in frame. That matters here more than for the Tendermint struct: +// a value that arrives from a flag or an environment variable is a string, while +// the same value from app.toml is a typed TOML scalar, and every downstream +// cast.To* sees the difference. +func FuzzApplyPrecedenceApp(f *testing.F) { + // Every row across every layer combination, so no row's precedence depends on the fuzzer + // being run by hand. Index 6 (concurrency-workers) is the reason this matters beyond + // coverage: it is the only row declaring WantGoType "int", so it is the whole contrast that + // makes the type assertion meaningful. Every other row expects "string", which a build that + // ignored the flag's declared type would also satisfy. + for i := range len(appKeys) { + for _, inFile := range []bool{false, true} { + for _, inEnv := range []bool{false, true} { + for _, inFlag := range []bool{false, true} { + f.Add(uint(i), inFile, inEnv, inFlag) + } + } + } + } + + f.Add(uint(0), false, false, false) + f.Add(uint(0), true, false, false) + f.Add(uint(0), false, true, false) + f.Add(uint(0), false, false, true) + f.Add(uint(0), true, true, true) + f.Add(uint(2), true, true, false) + f.Add(uint(2), true, false, true) + f.Add(uint(4), false, true, true) + f.Add(uint(5), true, true, true) + + f.Fuzz(func(t *testing.T, keyIdx uint, inFile, inEnv, inFlag bool) { + configtest.Isolate(t) + row := appKeys[keyIdx%uint(len(appKeys))] + home := configtest.NewHome(t) + + // app.toml has to exist for this key to come from the file layer, and a + // file that exists is never rewritten, so writing just the one key is + // enough — and is also the shape of an app.toml from an older release. + if inFile { + literal := fmt.Sprintf("%q", row.Values[0]) + if row.Numeric { + literal = row.Values[0] // an unquoted TOML integer, so viper returns int64 + } + home.WriteAppTOML(t, []byte(fmt.Sprintf("%s = %s\n", row.Key, literal))) + } + if inEnv { + setServerEnv(t, row.Key, row.Values[1]) + } + flagValues := map[string]string{} + if inFlag { + flagValues[row.Key] = row.Values[2] + } + + got := applyLegacy(t, home, flagValues) + if got.err != nil { + t.Fatalf("%s: Apply must succeed with legal values in every layer, got %v", row.Key, got.err) + } + + want := "" + switch { + case inFlag: + want = row.Values[2] + case inEnv: + want = row.Values[1] + case inFile: + want = row.Values[0] + } + if want == "" { + return // nothing set; the default is the template's business, not this row's + } + + raw := got.ctx.Viper.Get(row.Key) + if resolved := fmt.Sprintf("%v", raw); resolved != want { + t.Fatalf("%s resolved to %q, want the value from the highest present layer (%q)\n"+ + "layers: file=%v env=%v flag=%v", row.Key, resolved, want, inFile, inEnv, inFlag) + } + + // The resolved Go type is decided by the flag's declared type, not by which + // layer supplied the value, and not by the TOML scalar's own type. + // + // bindFlags copies whatever viper resolved back into the cobra flag for every + // bound flag, which marks it Changed. viper then answers Get from the flag, + // converting only the types its switch names — int and its widths, bool, and + // the slice/map kinds. A uint64 flag falls through to the default branch and + // comes back as the flag's text. So halt-height written as an unquoted TOML + // integer still reaches appOpts.Get as a string, while concurrency-workers + // comes back as an int. + // + // Every downstream reader is a cast.To*, which absorbs the difference — which + // is exactly why this stays invisible until a second manager puts typed values + // in the viper and the differential compares types. + if row.WantGoType != "" { + if got := fmt.Sprintf("%T", raw); got != row.WantGoType { + t.Fatalf("%s resolved to Go type %s (%#v), want %s\n"+ + "layers: file=%v env=%v flag=%v", row.Key, got, raw, row.WantGoType, inFile, inEnv, inFlag) + } + } + }) +} + +// FuzzApplyEnvReachesTheStructOnlyForStructurallyKnownKeys pins the sharpest edge +// in the legacy environment story: one environment variable reaches one boot +// channel and not the other, and which one depends on whether the node has booted +// before. +// +// The server viper runs AutomaticEnv, which resolves at Get time — viper.Get(key) +// consults the environment for any key at all. serverCtx.Config, by contrast, is +// produced by viper.Unmarshal, which only walks keys viper knows structurally: +// those bound to a cobra flag, and those present in a config.toml it actually +// read. Those two sets are not the same, so the channels disagree. +// +// The part no operator could guess is that the set changes on the second boot. +// Creating config.toml and reading config.toml are separate branches: the branch +// that writes a fresh file never reads it back, while the branch that finds an +// existing file calls ReadInConfig. So a key that lives in the rendered template +// but has no flag — p2p.queue-type is one — is invisible to Unmarshal on a fresh +// home and visible on every boot afterwards. The same SEID_P2P_QUEUE_TYPE is +// therefore inert on a node's first start and effective on its restart, while +// being visible to every appOpts.Get() both times. +// +// A replacement manager that resolves every key uniformly diverges here, which is +// a decision to ratify rather than a difference to find in production. +func FuzzApplyEnvReachesTheStructOnlyForStructurallyKnownKeys(f *testing.F) { + f.Add("priority") + f.Add("simple-priority") // the key's own default: the assertion must not rely on inequality + f.Add("fifo") + // An environment variable is bytes, not text, and viper hands whatever it holds + // straight through. Found by the fuzzer; kept as a seed. + f.Add("\xeb") + + f.Fuzz(func(t *testing.T, envValue string) { + if envValue == "" || !configtest.EnvValueIsSettable(envValue) { + return // an empty variable reads as unset, and a NUL cannot be exported + } + configtest.Isolate(t) + + // p2p.queue-type has no cobra flag (neither AddNodeFlags nor + // addStartNodeFlags registers one) and is rendered in the config.toml + // template. That combination is what makes it structurally unknown on a + // fresh home and known on a re-boot. + const key = "p2p.queue-type" + const path = "P2P.QueueType" + + home := configtest.NewHome(t) + setServerEnv(t, key, envValue) + fromEnv := configtest.DumpAt(path, envValue) + + // First boot: config.toml is created and not read back, so the key is + // structurally unknown and Unmarshal cannot see the environment. + first := applyLegacy(t, home, nil) + if first.err != nil { + t.Fatalf("first Apply must succeed, got %v", first.err) + } + firstLeaf, ok := configtest.LeafAt(configtest.Dump(*first.ctx.Config), path) + if !ok { + t.Fatalf("%q is not present in the resolved Tendermint config", path) + } + // A fuzzer that happens to generate the key's own default makes firstLeaf equal + // fromEnv without the environment having reached anything, so that case is exempt. + // The default is read from tmcfg rather than written as a literal: hardcoding it + // couples this row to one spelling, and the row would start failing for real the + // first time the default moved and the fuzzer reached the new value. + defaultLeaf, ok := configtest.LeafAt(configtest.Dump(*tmcfg.DefaultConfig()), path) + if !ok { + t.Fatalf("%q is not present in the default Tendermint config", path) + } + if firstLeaf == fromEnv && firstLeaf != defaultLeaf { + t.Fatalf("on a fresh home the environment reached serverCtx.Config for a flag-less key (%s). "+ + "If the creation branch now reads back the config.toml it wrote, that changes which "+ + "SEID_* variables take effect on a node's first start", firstLeaf) + } + + // Both boots: viper resolves the environment regardless, because + // AutomaticEnv answers at Get time. + if resolved := fmt.Sprintf("%v", first.ctx.Viper.Get(key)); resolved != envValue { + t.Fatalf("serverCtx.Viper.Get(%q) = %q, want the environment value %q", key, resolved, envValue) + } + + // Second boot: config.toml now exists, ReadInConfig runs, the key becomes + // structurally known, and the same variable now moves the struct. + second := applyLegacy(t, home, nil) + if second.err != nil { + t.Fatalf("second Apply must succeed, got %v", second.err) + } + secondLeaf, ok := configtest.LeafAt(configtest.Dump(*second.ctx.Config), path) + if !ok { + t.Fatalf("%q is not present in the resolved Tendermint config", path) + } + // The first boot wrote the template default into config.toml, so when the environment + // carries that same value the file and the environment agree and secondLeaf matches + // whichever one won. That says nothing about the restart property, so it is not + // asserted here. TestFlaglessEnvKeyTakesEffectOnlyAfterTheFirstBoot covers the + // property with a value chosen to differ, so the coverage does not depend on what the + // fuzzer generates. + if fromEnv != defaultLeaf && secondLeaf != fromEnv { + t.Fatalf("on a materialized home the environment must reach serverCtx.Config\n got: %s\nwant: %s", + secondLeaf, fromEnv) + } + if resolved := fmt.Sprintf("%v", second.ctx.Viper.Get(key)); resolved != envValue { + t.Fatalf("serverCtx.Viper.Get(%q) = %q, want the environment value %q", key, resolved, envValue) + } + }) +} + +// FuzzApplyMalformedConfigTOML feeds arbitrary bytes to config.toml. The property +// is total behavior: Apply either succeeds or returns an error, and never panics, +// leaves the process wedged, or reports success on a file it could not read. +// +// It matters because the file is operator-authored and edited under pressure. A +// truncated write, a stray shell heredoc, a half-applied sed — each produces bytes +// like these, and the difference between "the node refuses to start and says why" +// and "the node starts on partially-parsed config" is the difference between an +// outage and a silent misconfiguration. +func FuzzApplyMalformedConfigTOML(f *testing.F) { + f.Add([]byte("")) + f.Add([]byte("moniker = \"ok\"\n")) + f.Add([]byte("moniker = \n")) + f.Add([]byte("[rpc\nladdr = \"x\"\n")) + f.Add([]byte("[[[[[")) + f.Add([]byte("log-level = 42\n")) + f.Add([]byte("log-level = \"not-a-level\"\n")) + f.Add([]byte("mode = \"\"\n")) + f.Add([]byte("rpc.laddr = 1\n")) + f.Add([]byte("\x00\x01\x02")) + f.Add([]byte("moniker = \"a\"\nmoniker = \"b\"\n")) + + f.Fuzz(func(t *testing.T, contents []byte) { + configtest.Isolate(t) + home := configtest.NewHome(t) + home.WriteConfigTOML(t, contents) + + got := applyLegacy(t, home, nil) + + if got.err != nil { + return + } + if got.ctx.Config == nil { + t.Fatal("Apply reported success but left serverCtx.Config nil") + } + if got.ctx.Viper == nil { + t.Fatal("Apply reported success but left serverCtx.Viper nil") + } + // Note what is deliberately *not* asserted: that a successful Apply leaves a + // valid config. It does not, and cannot be made to here — ValidateBasic runs + // only on the file-creation path. See + // TestApplyDoesNotValidateAPreExistingConfigFile. + if got.ctx.Config.RootDir != home.Root { + t.Fatalf("serverCtx.Config.RootDir = %q, want the resolved home %q", got.ctx.Config.RootDir, home.Root) + } + }) +} + +// TestFlaglessEnvKeyTakesEffectOnlyAfterTheFirstBoot pins the restart asymmetry with a +// value chosen to differ from the template default, so the property is exercised on every +// run rather than only when the fuzzer happens to generate a non-default value. +// +// The fuzz target above covers the same key across arbitrary values, but its second-boot +// assertion has to stand down when the generated value equals the default: the first boot +// writes that default into config.toml, so file and environment agree and the resolved value +// no longer says which one won. This row removes that dependence. +func TestFlaglessEnvKeyTakesEffectOnlyAfterTheFirstBoot(t *testing.T) { + configtest.Isolate(t) + + const key = "p2p.queue-type" + const path = "P2P.QueueType" + + defaultLeaf, ok := configtest.LeafAt(configtest.Dump(*tmcfg.DefaultConfig()), path) + if !ok { + t.Fatalf("%q is not present in the default Tendermint config", path) + } + // Two legal queue types, so whichever is the default the probe differs from it. + value := "fifo" + if configtest.DumpAt(path, value) == defaultLeaf { + value = "priority" + } + fromEnv := configtest.DumpAt(path, value) + if fromEnv == defaultLeaf { + t.Fatalf("both probe values match the default (%s); pick another", defaultLeaf) + } + + home := configtest.NewHome(t) + setServerEnv(t, key, value) + + first := applyLegacy(t, home, nil) + if first.err != nil { + t.Fatalf("first Apply: %v", first.err) + } + firstLeaf, ok := configtest.LeafAt(configtest.Dump(*first.ctx.Config), path) + if !ok { + t.Fatalf("%q is not present in the resolved Tendermint config", path) + } + if firstLeaf != defaultLeaf { + t.Fatalf("on a fresh home a flag-less key must resolve its default, not the environment\n"+ + " got: %s\nwant: %s", firstLeaf, defaultLeaf) + } + + second := applyLegacy(t, home, nil) + if second.err != nil { + t.Fatalf("second Apply: %v", second.err) + } + secondLeaf, ok := configtest.LeafAt(configtest.Dump(*second.ctx.Config), path) + if !ok { + t.Fatalf("%q is not present in the resolved Tendermint config", path) + } + if secondLeaf != fromEnv { + t.Fatalf("on a restart the same SEID_* variable must reach serverCtx.Config. This is the "+ + "asymmetry the row exists for: inert on first start, effective on every one after\n"+ + " got: %s\nwant: %s", secondLeaf, fromEnv) + } +} + +// TestApplyDoesNotValidateAPreExistingConfigFile records the validation gap the +// legacy path leaves, and it is the single most important row in this file for the +// ConfigManager work, because closing it is one of the new manager's stated goals. +// +// interceptConfigs calls conf.ValidateBasic() only on the branch that *creates* +// config.toml. When the file already exists it is read, unmarshalled, and handed +// back unvalidated. So a config.toml with `mode = ""` — the shape a half-finished +// edit or a templating bug produces — passes Apply cleanly and takes the node down +// later, from node.New, with an error that points at consensus setup rather than at +// the file. +// +// This is exactly the "silent misconfiguration" class the design proposes to make +// structurally extinct by halting at boot validation with the key named. Pinning +// the current behavior is what lets the new manager's halt be recognized as an +// intentional, ratified divergence rather than a regression. +func TestApplyDoesNotValidateAPreExistingConfigFile(t *testing.T) { + configtest.Isolate(t) + + home := configtest.NewHome(t) + home.WriteConfigTOML(t, []byte("mode = \"\"\n")) + + got := applyLegacy(t, home, nil) + if got.err != nil { + t.Fatalf("legacy Apply must not reject an invalid pre-existing config.toml, got %v", got.err) + } + if err := got.ctx.Config.ValidateBasic(); err == nil { + t.Fatal("mode = \"\" now passes ValidateBasic; the fixture no longer exercises the gap") + } + // Restated as the property, so the test fails if Apply starts validating: + // success from Apply does not imply a bootable config. +} + +// TestApplyMergesAppTOMLAfterUnmarshallingTheTendermintConfig pins the ordering +// inside interceptConfigs, which produces a divergence between the two channels +// that no operator could guess. +// +// config.toml is read and unmarshalled into the Tendermint struct *before* app.toml +// is merged, and both files land in one flat viper namespace where app.toml wins +// collisions. So a key that exists in both files resolves one way in +// serverCtx.Config (config.toml's value, because the struct was already built) and +// the other way in serverCtx.Viper (app.toml's value, because it merged last). +// +// A single app.toml key can therefore shadow a Tendermint setting for every +// appOpts.Get() reader while leaving the Tendermint node itself on the config.toml +// value. +func TestApplyMergesAppTOMLAfterUnmarshallingTheTendermintConfig(t *testing.T) { + configtest.Isolate(t) + + home := configtest.NewHome(t) + home.WriteConfigTOML(t, []byte("moniker = \"from-config-toml\"\n")) + home.WriteAppTOML(t, []byte("moniker = \"from-app-toml\"\n")) + + got := applyLegacy(t, home, nil) + if got.err != nil { + t.Fatalf("Apply: %v", got.err) + } + + if got.ctx.Config.Moniker != "from-config-toml" { + t.Errorf("serverCtx.Config.Moniker = %q, want config.toml's value: the struct is "+ + "unmarshalled before app.toml is merged", got.ctx.Config.Moniker) + } + if resolved := fmt.Sprintf("%v", got.ctx.Viper.Get("moniker")); resolved != "from-app-toml" { + t.Errorf("serverCtx.Viper.Get(\"moniker\") = %q, want app.toml's value: both files "+ + "share one flat namespace and app.toml is merged last", resolved) + } +} + +// FuzzApplyMalformedAppTOML feeds arbitrary bytes to app.toml. The extra property +// on this side is that app.toml and config.toml share one flat viper namespace — +// app.toml is merged into the same instance that already holds config.toml, and +// wins on collisions — so a malformed app.toml can only fail the merge, never +// silently reshape the Tendermint config that was already unmarshalled. +func FuzzApplyMalformedAppTOML(f *testing.F) { + f.Add([]byte("")) + f.Add([]byte("halt-height = 1\n")) + f.Add([]byte("halt-height = \n")) + f.Add([]byte("freeze-height = 1\n")) + f.Add([]byte("[telemetry\nenabled = true\n")) + f.Add([]byte("moniker = \"app-toml-wins\"\n")) + f.Add([]byte("telemetry.global-labels = \"not-a-list\"\n")) + f.Add([]byte("\xff\xfe\x00")) + f.Add([]byte("[[[[[")) + + f.Fuzz(func(t *testing.T, contents []byte) { + configtest.Isolate(t) + home := configtest.NewHome(t) + home.WriteAppTOML(t, contents) + + got := applyLegacy(t, home, nil) + + if got.err != nil { + return + } + if got.ctx.Config == nil || got.ctx.Viper == nil { + t.Fatal("Apply reported success but left a boot channel nil") + } + // app.toml is merged after the Tendermint struct is built, so no app.toml + // content can make the struct invalid — whatever these bytes contain, the + // struct came from config.toml (here: the freshly created defaults). + if err := got.ctx.Config.ValidateBasic(); err != nil { + t.Fatalf("app.toml content must not be able to invalidate the Tendermint config, got %v", err) + } + }) +} + +// FuzzApplyIsIdempotent pins the property that makes every other assertion in +// this file meaningful: booting the same home twice resolves to the same thing. +// +// It is not trivially true. The first Apply on a fresh home *writes* both files — +// config.toml with hardcoded values that DefaultConfig does not carry, and +// app.toml rendered from whatever the viper held at that moment — and the second +// Apply reads what the first one wrote. So this target is really asking whether +// materialization is a fixed point, which is what lets a node restart without its +// configuration drifting, and what lets the differential harness compare two +// managers on a fixture home at all. +// +// The freshly-generated app.toml carries a randomized pruning-interval and a +// hostname-derived moniker, so the comparison is deliberately between run two and +// run three (both of which read files rather than writing them) with run one +// serving only to materialize. +func FuzzApplyIsIdempotent(f *testing.F) { + f.Add(false, false, "") + f.Add(true, false, "") + f.Add(false, true, "") + f.Add(true, true, "") + f.Add(true, true, "tcp://127.0.0.1:26656") + + f.Fuzz(func(t *testing.T, seedConfig, seedApp bool, p2pLaddr string) { + configtest.Isolate(t) + home := configtest.NewHome(t) + if seedConfig { + home.WriteConfigTOML(t, []byte("moniker = \"fixture\"\n")) + } + if seedApp { + home.WriteAppTOML(t, []byte("halt-height = 7\n")) + } + flagValues := map[string]string{} + if p2pLaddr != "" { + flagValues["p2p.laddr"] = p2pLaddr + } + + if first := applyLegacy(t, home, flagValues); first.err != nil { + // The laddr is the only input here the fuzzer can make unusable, so with none set + // there is nothing to attribute a failure to and materializing has to succeed. + // Skipping in that case would let a regression that stops a seeded row from + // materializing pass as a skip, which is the outcome this suite exists to prevent. + // A non-empty laddr still declines, the same move IsTOMLWritable makes, rather than + // re-deriving what the p2p layer accepts. + if p2pLaddr == "" { + t.Fatalf("materializing boot with no laddr override must succeed: %v", first.err) + } + t.Skipf("materializing boot failed for laddr %q (%v); malformed-input behavior is covered elsewhere", + p2pLaddr, first.err) + } + if !home.Exists("config.toml") || !home.Exists("app.toml") { + t.Fatal("the first Apply must leave both config.toml and app.toml on disk") + } + + second := applyLegacy(t, home, flagValues) + third := applyLegacy(t, home, flagValues) + if second.err != nil || third.err != nil { + t.Fatalf("re-booting a materialized home must succeed: %v / %v", second.err, third.err) + } + + if a, b := configtest.Dump(*second.ctx.Config), configtest.Dump(*third.ctx.Config); a != b { + t.Fatalf("serverCtx.Config differs between two boots of the same home\n--- second\n%s\n--- third\n%s", a, b) + } + if a, b := configtest.DumpViper(second.ctx.Viper), configtest.DumpViper(third.ctx.Viper); a != b { + t.Fatalf("serverCtx.Viper differs between two boots of the same home\n--- second\n%s\n--- third\n%s", a, b) + } + }) +} + +// TestApplyMaterializationOverridesOnlyApplyToACreatedConfigFile pins one of the +// legacy path's least obvious behaviors, and one with a real operational +// consequence. +// +// When config.toml is absent, interceptConfigs writes it after stamping three +// values that neither tmcfg.DefaultConfig nor the template carries: a pprof +// listener on localhost:6060 and P2P receive and send rates of 5120000. When +// config.toml is present, those stampings do not happen — the file is simply read. +// +// So two nodes on the same binary run different RPC and P2P settings depending +// only on whether their config.toml was generated by this code path or by an +// earlier one. Deleting and regenerating a config.toml changes node behavior, +// which is the opposite of what "regenerate the defaults" implies. +func TestApplyMaterializationOverridesOnlyApplyToACreatedConfigFile(t *testing.T) { + configtest.Isolate(t) + + created := configtest.NewHome(t) + if got := applyLegacy(t, created, nil); got.err != nil { + t.Fatalf("Apply on an empty home: %v", got.err) + } + generated := applyLegacy(t, created, nil) + if generated.err != nil { + t.Fatalf("re-Apply on the materialized home: %v", generated.err) + } + + // A config.toml that exists but says nothing: the same binary, the same + // absent keys, and none of the creation-path stampings. + preexisting := configtest.NewHome(t) + preexisting.WriteConfigTOML(t, []byte("# authored by an earlier release\n")) + read := applyLegacy(t, preexisting, nil) + if read.err != nil { + t.Fatalf("Apply on a home with a minimal config.toml: %v", read.err) + } + + checks := []struct { + what string + fromGenerated any + fromRead any + }{ + {"RPC.PprofListenAddress", generated.ctx.Config.RPC.PprofListenAddress, read.ctx.Config.RPC.PprofListenAddress}, + {"P2P.RecvRate", generated.ctx.Config.P2P.RecvRate, read.ctx.Config.P2P.RecvRate}, + {"P2P.SendRate", generated.ctx.Config.P2P.SendRate, read.ctx.Config.P2P.SendRate}, + } + for _, c := range checks { + if fmt.Sprintf("%v", c.fromGenerated) == fmt.Sprintf("%v", c.fromRead) { + t.Errorf("%s no longer distinguishes a generated config.toml from a pre-existing one "+ + "(both %v). If the creation-path override was moved into DefaultConfig or the "+ + "template on purpose, that changes behavior for every existing node and needs a "+ + "migration, not just an updated test", c.what, c.fromGenerated) + } + } +} + +// TestServerEnvPrefixFollowsExecutableBasename pins the environment prefix to +// path.Base(os.Executable()) rather than to the literal "seid". +// +// This is the mechanism behind a genuinely surprising failure mode: renaming or +// symlinking the binary silently changes every environment variable the node +// responds to, so a deployment that invokes the node as `sei-node` ignores every +// SEID_* variable it is given, with no warning. The assertion is written as a +// relationship rather than a constant precisely so it holds inside a test binary +// — which is itself a differently-named executable, and therefore a live +// demonstration of the edge. +func TestServerEnvPrefixFollowsExecutableBasename(t *testing.T) { + configtest.Isolate(t) + + prefix, err := configtest.ServerEnvPrefix() + if err != nil { + t.Fatalf("resolve env prefix: %v", err) + } + if prefix == "seid" { + t.Skip("test binary is named seid; the prefix relationship is not observable here") + } + + const key = "rpc.laddr" + const want = "tcp://127.0.0.1:26699" + const ignored = "tcp://127.0.0.1:26698" + + derivedName := configtest.ServerEnvKey(prefix, key) + seidName := configtest.ServerEnvKey("seid", key) + if seidName == derivedName { + t.Fatalf("derived prefix %q collides with seid; cannot distinguish the two spellings", prefix) + } + + // The baseline is resolved with neither variable set, so the negative half below can + // assert an actual fallback rather than merely "not the value I set". + baseline := applyLegacy(t, configtest.NewHome(t), nil) + if baseline.err != nil { + t.Fatalf("Apply: %v", baseline.err) + } + unset := baseline.ctx.Config.RPC.ListenAddress + if unset == want || unset == ignored { + t.Fatalf("fixture default %q collides with a probe value; pick different probes", unset) + } + + // The derived name is honored... + setServerEnv(t, key, want) + got := applyLegacy(t, configtest.NewHome(t), nil) + if got.err != nil { + t.Fatalf("Apply: %v", got.err) + } + if got.ctx.Config.RPC.ListenAddress != want { + t.Fatalf("%s did not take effect; resolved %q, want %q", + derivedName, got.ctx.Config.RPC.ListenAddress, want) + } + + // ...and the "seid" spelling is not, because this binary is not named seid. Asserted by + // resolving it rather than by comparing the two names: that the spellings differ says + // nothing about which one Apply reads, so the derived variable is cleared and the seid + // one set alone. + if err := os.Unsetenv(derivedName); err != nil { + t.Fatalf("unset %s: %v", derivedName, err) + } + if err := os.Setenv(seidName, ignored); err != nil { + t.Fatalf("set %s: %v", seidName, err) + } + fresh := applyLegacy(t, configtest.NewHome(t), nil) + if fresh.err != nil { + t.Fatalf("Apply: %v", fresh.err) + } + if fresh.ctx.Config.RPC.ListenAddress != unset { + t.Fatalf("with only %s set the address resolved to %q, want the unset baseline %q. The "+ + "prefix is the literal seid rather than the executable basename %q, which would mean "+ + "a renamed binary keeps responding to SEID_* after all", + seidName, fresh.ctx.Config.RPC.ListenAddress, unset, prefix) + } +} + +// TestGeneratedAppTOMLDivergesFromTheWasmInCodeDefault pins the [wasm] gas divergence +// against the template seid actually renders. +// +// query_gas_limit is one of the few keys the template writes as a bare literal rather than +// a {{ .Field }} substitution, so the number lives in this package's template string and +// nothing derives it from wasmd's defaults. The consequence is the finding: a node whose +// app.toml seid generated runs smart queries at a tenth of the allowance of a node whose +// app.toml has no [wasm] section, and neither node looks misconfigured by its own file. +// +// The row belongs here rather than beside the reader. A test in the wasm package can only +// hand the reader a number and watch it come back, which proves the reader echoes its +// input and would stay green if the template changed. This reads what a fresh home +// materializes, so editing the literal in the template moves this assertion. +func TestGeneratedAppTOMLDivergesFromTheWasmInCodeDefault(t *testing.T) { + configtest.Isolate(t) + home := configtest.NewHome(t) + + // The expected value is stated here and compared against a real generated file, so it is + // an expectation rather than the echo it would be if it were fed to the reader. + const generatedLiteral = uint64(300000) + + got := applyLegacy(t, home, nil) + if got.err != nil { + t.Fatalf("Apply: %v", got.err) + } + if !home.Exists("app.toml") { + t.Fatal("Apply did not materialize app.toml, so this row is not reading a generated file") + } + raw := got.ctx.Viper.Get("wasm.query_gas_limit") + if raw == nil { + t.Fatal("a generated app.toml no longer carries wasm.query_gas_limit. If the key left the " + + "template, every generated node now runs smart queries at wasmd's in-code default " + + "instead, which raises the allowance tenfold") + } + fromTemplate, castErr := cast.ToUint64E(raw) + if castErr != nil { + t.Fatalf("wasm.query_gas_limit = %#v does not convert to uint64: %v", raw, castErr) + } + if fromTemplate != generatedLiteral { + t.Fatalf("a generated app.toml resolves wasm.query_gas_limit to %d, and this row expects "+ + "%d. The template literal moved: that changes the gas allowance on every node generated "+ + "from it, so update this row deliberately rather than to make it pass", + fromTemplate, generatedLiteral) + } + + inCode := wasmtypes.DefaultWasmConfig().SmartQueryGasLimit + if fromTemplate == inCode { + t.Fatalf("the template literal and wasmd's in-code default are both %d. Closing that "+ + "divergence changes what contract queries succeed on every node whose app.toml lacks "+ + "[wasm], so it is recorded here rather than skipped past", inCode) + } + if fromTemplate >= inCode { + t.Fatalf("a generated app.toml (%d) is no longer tighter than the in-code default (%d); the "+ + "direction of the divergence changed", fromTemplate, inCode) + } +} + +// TestGeneratedAppTOMLUsesTheSpellingsTheReadersLookUp pins that the template's section +// headers and key names match the keys the section readers actually resolve. +// +// The template writes headers and key names as literal text and resolves only values through +// {{ .Field }}, so the spellings are independent of the mapstructure tags on +// CustomAppConfig. Where the two disagree, the tag is the inert one: +// CustomAppConfig.ETHBlockTest is tagged eth_block_test while both the template and the +// reader use eth_blocktest. That divergence is harmless today precisely because generation +// does not consult the tags, which is the property this row holds. A manager that generated +// config from the struct tags instead would emit sections the readers ignore, and this is +// where that shows up. +func TestGeneratedAppTOMLUsesTheSpellingsTheReadersLookUp(t *testing.T) { + configtest.Isolate(t) + home := configtest.NewHome(t) + + got := applyLegacy(t, home, nil) + if got.err != nil { + t.Fatalf("Apply: %v", got.err) + } + + // Keys a reader looks up that must be present in the resolved view of a generated file, + // alongside the tag spelling that must not be. + for _, key := range []string{ + "eth_blocktest.eth_blocktest_enabled", + "eth_blocktest.eth_blocktest_test_data_path", + } { + if got.ctx.Viper.Get(key) == nil { + t.Fatalf("a generated app.toml does not carry %q, which the section reader looks up. "+ + "If the template's spelling changed, every generated node now resolves this key's "+ + "default instead of the file's value", key) + } + } + if v := got.ctx.Viper.Get("eth_block_test.eth_blocktest_enabled"); v != nil { + t.Fatalf("a generated app.toml now carries the mapstructure spelling eth_block_test (%#v). "+ + "Generation has started following the struct tags, and the reader looks up "+ + "eth_blocktest, so the section it writes is ignored", v) + } +} + +// TestStateCommitAsyncCommitBufferTagAddressesNoReader records a second inert mapstructure +// tag, and one with a sharper edge than eth_block_test's. +// +// Two fields carry the tag async-commit-buffer: StateCommitConfig.AsyncCommitBuffer, which +// nothing reads, and memiavl.Config.AsyncCommitBuffer, which decides whether a node commits +// synchronously. MemIAVLConfig carries no tag, so a tag-driven binder would reach the live +// field only at state-commit.memiavlconfig.async-commit-buffer while the dead one sits at the +// shallower state-commit.async-commit-buffer — and sc-async-commit-buffer, the spelling the +// template renders and both readers resolve, would address no field at all. So the shallower +// spelling shadows the live knob: an operator correcting an app.toml to the tag-advertised name +// has their value land in the dead field, and what the node then commits with depends on +// something the tags do not say. A binder that unmarshals over DefaultStateCommitConfig leaves +// the live buffer at 100; one that unmarshals into a zero struct leaves it at 0, which memiavl +// reads as synchronous commit. Neither outcome is the value the operator wrote. +// +// Recorded here rather than repaired: retagging either field changes what a tag-driven +// manager binds, and PLT-775 is where that is chosen. The binder is not hypothetical — +// sei-cosmos/server/util.go:302 unmarshals the root viper into the custom app config on the +// app.toml-absent branch, and reaches the dead field only once some layer makes +// state-commit.async-commit-buffer a key that viper holds. What this holds is that the divergence +// stays the one described above — the dead field keeps the tag the live field also carries, +// and neither is addressable at the spelling the readers use. +func TestStateCommitAsyncCommitBufferTagAddressesNoReader(t *testing.T) { + const ( + tag = "async-commit-buffer" + readerName = "sc-async-commit-buffer" + ) + tagOf := func(structType reflect.Type, field string) string { + t.Helper() + f, ok := structType.FieldByName(field) + if !ok { + t.Fatalf("%s has no field %s; this recording names fields that no longer exist", + structType, field) + } + return f.Tag.Get("mapstructure") + } + + if got := tagOf(reflect.TypeOf(seidbconfig.StateCommitConfig{}), "AsyncCommitBuffer"); got != tag { + t.Errorf("StateCommitConfig.AsyncCommitBuffer is now tagged %q, was %q. That field is read by "+ + "nothing, so retagging it to %q would make the dead field the one a tag-driven manager "+ + "binds under the spelling operators write, and the live memiavl field unreachable. If the "+ + "field was deleted or the collision closed, this recording is what has to change with it", + got, tag, readerName) + } + if got := tagOf(reflect.TypeOf(memiavl.Config{}), "AsyncCommitBuffer"); got != tag { + t.Errorf("memiavl.Config.AsyncCommitBuffer is now tagged %q, was %q. This is the live field: "+ + "<= 0 means synchronous commit. Both readers reach it through %q, which is a literal in "+ + "the app.toml template and in each reader, so moving the tag changes only what a "+ + "tag-driven binder addresses — and that is the change worth reviewing on its own", + got, tag, readerName) + } + if got := tagOf(reflect.TypeOf(seidbconfig.StateCommitConfig{}), "MemIAVLConfig"); got != "" { + t.Errorf("StateCommitConfig.MemIAVLConfig is now tagged %q, where it carried no tag. That "+ + "moves the live async-commit-buffer to state-commit.%s.%s for a tag-driven binder, and "+ + "whether it now shadows or is shadowed by the dead field is the whole of the review", + got, got, tag) + } + + // The live anchor: what a generated app.toml actually carries is the readers' spelling, and + // not the tag's. Without it the assertions above would hold equally in a tree where + // generation had started following the tags. + configtest.Isolate(t) + got := applyLegacy(t, configtest.NewHome(t), nil) + if got.err != nil { + t.Fatalf("Apply: %v", got.err) + } + if v := got.ctx.Viper.Get("state-commit." + readerName); v == nil { + t.Fatalf("a generated app.toml no longer carries state-commit.%s, which both readers look "+ + "up. Every app.toml on disk addresses that spelling, so the async commit queue on every "+ + "node just fell back to its in-code default", readerName) + } + if v := got.ctx.Viper.Get("state-commit." + tag); v != nil { + t.Fatalf("a generated app.toml now carries state-commit.%s (%#v). Generation has started "+ + "following the struct tags, and that spelling reaches the field nothing reads, so the "+ + "key an operator sets no longer changes how the node commits", tag, v) + } +} + +// TestKeyNamesMatchTheRecordedNames records the two [state-sync] keys whose constant nothing +// else in the tree holds. +// +// NewApp reads all three state-sync keys through the constants declared in +// sei-cosmos/server/start.go — `appOpts.Get(server.FlagStateSyncSnapshotDir)` at root.go:255 and +// the three baseapp.Set* calls at root.go:304-306. It is not the only reader, and the readers +// that name the keys name them as literals: sei-cosmos/server/config.GetConfig reads all three +// into StateSyncConfig at config.go:615-619, and the app.toml template writes all three as +// literal text at sei-cosmos/server/config/toml.go:76, :79 and :83. Those namings are what make +// this record necessary rather than redundant: a constant rename moves NewApp and leaves every +// literal where it was, so the suite stays green, the readers now disagree about which key they +// resolve, and the key an operator wrote reaches only one of them. +// +// A third reader names them neither way: ParseConfig (sei-cosmos/server/config/toml.go:272-277) +// unmarshals the section by mapstructure tag, so a tag rename moves it and leaves both the +// literals and the constants where they were — the mirror of the case above, and the same seam +// TestStateCommitAsyncCommitBufferTagAddressesNoReader records for [state-commit]. It reaches +// seid only on util.go:308's empty-template branch, and initAppConfig always supplies a template +// (root.go:426 assembles it, non-empty), so nothing here holds it. That unreachability is prose +// rather than an assertion: the branch goes live the moment any caller passes an empty template. +// +// snapshot-interval is the exception, and by accident of spelling rather than by design: appKeys +// above names it as a literal, so it does not move when the constant moves and a constant-only +// rename fails six seeds of FuzzApplyPrecedenceApp. The other two have no such assertion, so +// editing the constant renames an operator-facing key with this whole suite green. +// +// What each rename costs is why they are worth a record rather than a deferral: +// +// - snapshot-keep-recent is a registered flag defaulting to 2 (start.go:234), so a rename does +// not surface as a missing flag — it silently reverts an explicit `= 10` to 2, and the serving +// node prunes snapshots a joining node is part-way through downloading. It presents on the +// joining node, as state-sync failure against a serving node that looks healthy. +// - snapshot-directory is registered nowhere, so a rename silently drops an explicit path and +// snapshots land in $HOME/data/snapshots instead (root.go:255-257). It presents as disk +// pressure on whichever volume the home directory is on. +// +// They are recorded with no rows because a row predicts a resolved leaf and NewApp builds a whole +// baseapp against a materialized node directory rather than resolving an AppOpts into a struct. +// That is a property of this reader and not of the section: GetConfig's reading of the same three +// keys is describable and is described, by a three-row manifest and a state-sync record of its own +// in sei-cosmos/server/config. Here a KeyName claims the spelling and nothing else, which is what +// can be said truthfully about NewApp. The consequence is that no seeds check ties this record — +// that tie needs a manifest — so this call is the only thing holding it, unlike the thirteen +// sections where CheckEveryRowHasADiscriminatingSeed compares the record as well. +func TestKeyNamesMatchTheRecordedNames(t *testing.T) { + configtest.CheckKeyNames(t, "state-sync", nil, + server.FlagStateSyncSnapshotKeepRecent, + server.FlagStateSyncSnapshotDir) +} + +// TestTendermintKeyNamesMatchTheRecordedNames pins the operator-facing spelling of the five +// Tendermint keys FuzzApplyPrecedenceTendermint drives. +// +// tmKeys carries a local struct rather than a KeySpec table, because a precedence row needs three +// distinct legal values and a KeySpec has nowhere to put them. The consequence is that no manifest +// check reaches these keys, so their spelling had nothing holding it. A KeyName claims the spelling +// and nothing else, which is the same thing the state-sync record above does and for the same +// reason. +// +// Read from tmKeys rather than listed here, so a row added later is recorded without anyone +// remembering this call. +func TestTendermintKeyNamesMatchTheRecordedNames(t *testing.T) { + names := make([]configtest.KeyName, 0, len(tmKeys)) + for _, row := range tmKeys { + names = append(names, configtest.KeyName(row.Key)) + } + configtest.CheckKeyNames(t, "tendermint", nil, names...) +} + +// TestApplyLeavesBothChannelsPopulated states the seam's minimum contract, the one +// the ConfigManager interface documents: whichever manager runs, both channels +// come back populated. It is the assertion a new manager fails first. +func TestApplyLeavesBothChannelsPopulated(t *testing.T) { + configtest.Isolate(t) + home := configtest.NewHome(t) + + got := applyLegacy(t, home, nil) + if got.err != nil { + t.Fatalf("Apply on an empty fixture home must succeed, got %v", got.err) + } + if got.ctx.Config == nil { + t.Fatal("serverCtx.Config is nil") + } + if got.ctx.Viper == nil { + t.Fatal("serverCtx.Viper is nil") + } + if got.ctx.Config.RootDir != home.Root { + t.Fatalf("serverCtx.Config.RootDir = %q, want the resolved home %q", got.ctx.Config.RootDir, home.Root) + } + // The viper must carry the app sections app.New reads, not just tendermint keys. + for _, key := range []string{ + "state-commit.sc-enable", + "state-store.ss-enable", + "evm.http_enabled", + "giga_executor.enabled", + "admin_server.admin_enabled", + } { + if got.ctx.Viper.Get(key) == nil { + t.Errorf("serverCtx.Viper is missing %q, which app.New reads through appOpts.Get", key) + } + } +} + +// TestWiringMatchesTheRecord pins which checks each of this package's sections is wired to. +// +// Every other check here reports a change to what it asserts. None reports a check being removed, so +// this records the wiring and fails when it thins out. +func TestWiringMatchesTheRecord(t *testing.T) { + configtest.CheckWiring(t) +} diff --git a/sei-cosmos/server/config/config.go b/sei-cosmos/server/config/config.go index 54475c8fdd..cb41f0cbc6 100644 --- a/sei-cosmos/server/config/config.go +++ b/sei-cosmos/server/config/config.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "math" "runtime" "strings" @@ -12,6 +13,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-db/config" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/memiavl" tmcfg "github.com/sei-protocol/sei-chain/sei-tendermint/config" + "github.com/spf13/cast" "github.com/spf13/viper" ) @@ -65,6 +67,10 @@ type BaseConfig struct { // Note: Commitment of state will be attempted on the corresponding block. HaltHeight uint64 `mapstructure:"halt-height"` + // FreezeHeight contains a non-zero block height at which the node stops + // before executing the block while continuing to serve RPC. + FreezeHeight uint64 `mapstructure:"freeze-height"` + // HaltTime contains a non-zero minimum block time (in Unix seconds) at which // a node will gracefully halt and shutdown that can be used to assist // upgrades and testing. @@ -258,6 +264,7 @@ func DefaultConfig() *Config { PruningKeepRecent: "0", PruningKeepEvery: "0", PruningInterval: "0", + FreezeHeight: 0, MinRetainBlocks: 0, IndexEvents: nil, CompactionInterval: 0, @@ -314,6 +321,10 @@ func GetConfig(v *viper.Viper) (Config, error) { if !ok { return Config{}, fmt.Errorf("failed to parse global-labels config") } + freezeHeight, err := cast.ToUint64E(v.Get("freeze-height")) + if err != nil { + return Config{}, fmt.Errorf("invalid freeze-height: %w", err) + } globalLabels := make([][]string, 0, len(globalLabelsRaw)) for idx, glr := range globalLabelsRaw { @@ -347,6 +358,7 @@ func GetConfig(v *viper.Viper) (Config, error) { PruningKeepRecent: v.GetString("pruning-keep-recent"), PruningInterval: v.GetString("pruning-interval"), HaltHeight: v.GetUint64("halt-height"), + FreezeHeight: freezeHeight, HaltTime: v.GetUint64("halt-time"), IndexEvents: v.GetStringSlice("index-events"), MinRetainBlocks: v.GetUint64("min-retain-blocks"), @@ -427,7 +439,7 @@ func GetConfig(v *viper.Viper) (Config, error) { }, nil } -// ValidateBasic returns an error if min-gas-prices field is empty in BaseConfig. Otherwise, it returns nil. +// ValidateBasic validates the server configuration. func (c Config) ValidateBasic(tendermintConfig *tmcfg.Config) error { if c.MinGasPrices == "" { return sdkerrors.ErrAppConfig.Wrap("set min gas price in app.toml or flag or env variable") @@ -437,6 +449,17 @@ func (c Config) ValidateBasic(tendermintConfig *tmcfg.Config) error { "cannot enable state sync snapshots with '%s' pruning setting", storetypes.PruningOptionEverything, ) } + return c.ValidateFreeze() +} + +// ValidateFreeze validates the configuration that controls freeze mode. +func (c Config) ValidateFreeze() error { + if c.FreezeHeight > math.MaxInt64 { + return sdkerrors.ErrAppConfig.Wrapf("freeze-height must not exceed %d", int64(math.MaxInt64)) + } + if c.FreezeHeight > 0 && (c.HaltHeight > 0 || c.HaltTime > 0) { + return sdkerrors.ErrAppConfig.Wrap("freeze-height cannot be combined with halt-height or halt-time") + } return nil } diff --git a/sei-cosmos/server/config/config_fuzz_test.go b/sei-cosmos/server/config/config_fuzz_test.go new file mode 100644 index 0000000000..e77c9022d1 --- /dev/null +++ b/sei-cosmos/server/config/config_fuzz_test.go @@ -0,0 +1,1528 @@ +package config + +import ( + "fmt" + "reflect" + "runtime" + "sort" + "strings" + "testing" + "time" + + "github.com/sei-protocol/sei-chain/sei-db/config" + sctypes "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" + "github.com/sei-protocol/sei-chain/testutil/configtest" + "github.com/sei-protocol/sei-chain/testutil/fuzzing" + "github.com/spf13/viper" +) + +// GetConfig is the second parser of app.toml, and the one that feeds api, grpc, +// grpc-web, rosetta, telemetry and state-sync. app/seidb.go parses [state-commit] +// and [state-store] out of the same viper for the store; GetConfig parses them +// again, by a different mechanism (viper.IsSet plus typed getters rather than +// appOpts.Get plus cast), into a Config nobody hands to the store. +// +// Two parsers of one section is the drift risk the manifest names, and the reason +// this file exists: it pins GetConfig's own resolution rules so a change that +// unified the parsers would show up as a diff here rather than as two components +// disagreeing about the same key at runtime. +// +// Everything below drives a bare viper.New, which is what GetConfig takes. That +// deliberately excludes flag binding and env resolution — those belong to Apply and +// are pinned in cmd/seid/cmd. What is left is the parse itself. + +// newAppViper returns a viper holding the one key GetConfig unconditionally +// requires, plus whatever the caller adds. telemetry.global-labels has no default +// and no presence guard, so nothing can be parsed without it. +func newAppViper(t testing.TB, keys map[string]any) *viper.Viper { + t.Helper() + v := viper.New() + v.Set("telemetry.global-labels", []any{}) + for k, val := range keys { + v.Set(k, val) + } + return v +} + +// FuzzGetConfigGlobalLabels pins the one key that can stop a node booting by being +// absent rather than wrong. +// +// telemetry.global-labels is read as a bare type assertion to []interface{} with no +// presence check, so an app.toml that omits the key entirely fails GetConfig +// outright — a node provisioned before the key existed does not start. Inside, each +// label is asserted to []interface{} and then its two elements to string with no +// checked assertion, so a label list holding a non-string panics rather than +// erroring. +// +// The shape rules are otherwise permissive in a way no operator would guess: a +// label whose length is not exactly 2 is silently dropped, not rejected. +func FuzzGetConfigGlobalLabels(f *testing.F) { + f.Add(0, 0, "chain") // no labels + f.Add(1, 2, "chain") // one well-formed pair + f.Add(3, 2, "chain") // several pairs + f.Add(1, 1, "chain") // one element: silently dropped + f.Add(1, 3, "chain") // three elements: silently dropped + f.Add(1, 0, "chain") // empty label + f.Add(2, 2, "") // empty strings are legal label content + f.Add(1, 2, "a=b,c=d") // punctuation is not special + + f.Fuzz(func(t *testing.T, labelCount, elemsPerLabel int, content string) { + // Keep the generated document small; the shape rules are what matter. + if labelCount < 0 || labelCount > 8 || elemsPerLabel < 0 || elemsPerLabel > 4 { + return + } + + labels := make([]any, 0, labelCount) + for i := range labelCount { + elems := make([]any, 0, elemsPerLabel) + for j := range elemsPerLabel { + elems = append(elems, fmt.Sprintf("%s-%d-%d", content, i, j)) + } + labels = append(labels, elems) + } + + v := viper.New() + v.Set("telemetry.global-labels", labels) + cfg, err := GetConfig(v) + if err != nil { + t.Fatalf("a well-typed global-labels list must parse, got %v", err) + } + + // Only two-element labels survive; the rest vanish without a diagnostic. + wantKept := 0 + if elemsPerLabel == 2 { + wantKept = labelCount + } + if len(cfg.Telemetry.GlobalLabels) != wantKept { + t.Fatalf("%d labels of %d elements resolved to %d kept, want %d "+ + "(a label whose length is not exactly 2 is dropped silently)", + labelCount, elemsPerLabel, len(cfg.Telemetry.GlobalLabels), wantKept) + } + }) +} + +// TestGetConfigRequiresGlobalLabels pins the absent-key failure on its own. This is +// the row that turns a missing telemetry section into a node that will not start. +func TestGetConfigRequiresGlobalLabels(t *testing.T) { + _, err := GetConfig(viper.New()) + if err == nil { + t.Fatal("an app.toml with no telemetry.global-labels must fail GetConfig; " + + "if a presence guard was added, that changes which existing app.toml files boot") + } + if !strings.Contains(err.Error(), "global-labels") { + t.Fatalf("the failure must name the key, got %v", err) + } +} + +// TestGetConfigPanicsOnNonStringLabel records that the inner element assertions are +// unchecked. A label list of the right shape but the wrong element type takes the +// node down with a raw interface-conversion panic rather than an error naming +// telemetry. +func TestGetConfigPanicsOnNonStringLabel(t *testing.T) { + v := viper.New() + v.Set("telemetry.global-labels", []any{[]any{1, 2}}) + + defer func() { + if r := recover(); r == nil { + t.Fatal("a non-string label must panic; if it is now an error, the diagnostic " + + "improved and this row should say so") + } + }() + _, _ = GetConfig(v) +} + +// grpcClamp is a duration key GetConfig clamps rather than accepts verbatim. +type grpcClamp struct { + Key string + Path string + Default time.Duration +} + +var grpcClamps = []grpcClamp{ + {Key: "grpc.max-connection-idle", Path: "GRPC.MaxConnectionIdle", Default: DefaultGRPCMaxConnectionIdle}, + {Key: "grpc.keepalive-time", Path: "GRPC.KeepaliveTime", Default: DefaultGRPCKeepaliveTime}, + {Key: "grpc.keepalive-timeout", Path: "GRPC.KeepaliveTimeout", Default: DefaultGRPCKeepaliveTimeout}, + {Key: "grpc.keepalive-min-time", Path: "GRPC.KeepaliveMinTime", Default: DefaultGRPCKeepaliveMinTime}, + {Key: "grpc.max-connection-age", Path: "GRPC.MaxConnectionAge", Default: DefaultGRPCMaxConnectionAge}, + { + Key: "grpc.max-connection-age-grace", Path: "GRPC.MaxConnectionAgeGrace", + Default: DefaultGRPCMaxConnectionAgeGrace, + }, +} + +// FuzzGetConfigGRPCDurationClamps pins the negative-duration clamp on the gRPC +// keepalive keys. +// +// gRPC accepts a negative keepalive verbatim and behaves pathologically, so GetConfig +// substitutes the in-code default instead of passing it through. Only a negative is +// clamped, uniformly across all six keys: zero passes through everywhere, which matters +// most on the two age keys where gRPC reads zero as "no limit". Distinguishing negative +// from zero rather than treating both as unset is what this target holds in place. +// +// Each value is driven in two shapes, because a typed time.Duration is not a shape any +// app.toml can produce. A file gives "30s" or a bare integer and an environment variable +// always gives a string, so the typed form skips the cast.ToDuration step that sits between +// the file layer and this comparison. The string spelling is the one an operator actually +// writes, and "-1s" is how they would express the boundary that matters here. +func FuzzGetConfigGRPCDurationClamps(f *testing.F) { + f.Add(uint(0), int64(0), false) + f.Add(uint(0), int64(-1), false) + f.Add(uint(0), int64((30 * time.Second)), false) + f.Add(uint(4), int64(0), false) + f.Add(uint(4), int64(-1), false) + f.Add(uint(1), int64(-1000000000), false) + f.Add(uint(2), int64((time.Hour)), false) + // The same values as an operator would write them. + f.Add(uint(0), int64((30 * time.Second)), true) + f.Add(uint(0), int64(-1000000000), true) // "-1s" + f.Add(uint(4), int64(0), true) + f.Add(uint(2), int64((time.Hour)), true) + + f.Fuzz(func(t *testing.T, keyIdx uint, nanos int64, asString bool) { + row := grpcClamps[keyIdx%uint(len(grpcClamps))] + d := time.Duration(nanos) + + // A typed duration and its own String() spelling must resolve identically, since + // viper.GetDuration casts the text back. Any divergence is in cast, not in the clamp, + // and it belongs to this row because the file layer only ever produces the text. + var raw any = d + if asString { + // Only a spelling that parses back to the same duration is a faithful stand-in for + // the typed value. Duration.String has not always round-tripped at the int64 + // boundary, and a spelling the parser rejects would resolve to zero and fail this + // row with a message about the clamp rather than about the encoding, so a value + // with no faithful text form is declined instead. + spelled := d.String() + if back, perr := time.ParseDuration(spelled); perr != nil || back != d { + return // no faithful text spelling; the typed shape already covers this value + } + raw = spelled + } + + cfg, err := GetConfig(newAppViper(t, map[string]any{row.Key: raw})) + if err != nil { + t.Fatalf("%s = %#v must parse, got %v", row.Key, raw, err) + } + + want := d + if d < 0 { + want = row.Default + } + got, ok := configtest.LeafAt(configtest.Dump(cfg), row.Path) + if !ok { + t.Fatalf("%s resolves into %q, which is not in the parsed config", row.Key, row.Path) + } + if wantLeaf := configtest.DumpAt(row.Path, want); got != wantLeaf { + t.Fatalf("%s = %#v resolved wrongly\n got: %s\nwant: %s\n"+ + "a negative duration falls back to the in-code default; zero passes through, and "+ + "the typed and string spellings must agree", + row.Key, raw, got, wantLeaf) + } + }) +} + +// FuzzGetConfigWriteMode pins GetConfig's own copy of the write-mode resolution. +// +// The rules match app/seidb.go — always parse, then let sc-write-mode-enable-auto +// (default true, flipped only by an explicit key) decide whether the parsed mode is +// honored — but the mechanism differs: GetConfig returns an error where seidb.go +// panics. Both parsers must agree on the resolved mode for a node's store choice +// and its reported config to describe the same thing, so the agreement is asserted +// against the shared helpers rather than restated. +func FuzzGetConfigWriteMode(f *testing.F) { + f.Add("memiavl_only", true, true) + f.Add("memiavl_only", true, false) + f.Add("cosmos_only", false, false) + f.Add("", false, false) + f.Add("", true, true) + f.Add("bogus", false, false) + f.Add("bogus", true, true) + f.Add("flatkv_only", false, false) + + f.Fuzz(func(t *testing.T, mode string, setAuto, auto bool) { + keys := map[string]any{"state-commit.sc-write-mode": mode} + if setAuto { + keys["state-commit.sc-write-mode-enable-auto"] = auto + } + + cfg, err := GetConfig(newAppViper(t, keys)) + + _, parseErr := config.ParseSCWriteMode(mode) + if mode != "" && parseErr != nil { + if err == nil { + t.Fatalf("sc-write-mode = %q does not parse and must be an error, not a panic or a fallback", mode) + } + if !strings.Contains(err.Error(), "sc-write-mode") { + t.Fatalf("the failure must name the key, got %v", err) + } + return + } + if err != nil { + t.Fatalf("sc-write-mode = %q must parse, got %v", mode, err) + } + + effectiveAuto := true + if setAuto { + effectiveAuto = auto + } + want := config.DefaultStateCommitConfig().WriteMode + if mode != "" { + parsed, perr := config.ParseSCWriteMode(mode) + if perr != nil { + t.Fatalf("mode %q was expected to parse: %v", mode, perr) + } + want = parsed + } + want = config.ApplyWriteModeAuto(effectiveAuto, want) + + if cfg.StateCommit.WriteMode != want { + t.Fatalf("sc-write-mode = %q with auto=%v resolved to %v, want %v", + mode, effectiveAuto, cfg.StateCommit.WriteMode, want) + } + if effectiveAuto && cfg.StateCommit.WriteMode != sctypes.Auto { + t.Fatalf("with auto on, the effective mode must be auto, got %v", cfg.StateCommit.WriteMode) + } + }) +} + +// guardedKey is a key GetConfig reads only when viper reports it set. +type guardedKey struct { + Key string + Path string + // Set is a value distinguishable from the default, used to prove the guard + // admits an explicit value as well as protecting an absent one. + Set any + // DefaultIsZero marks a key whose in-code default is already the zero value. + // The guard still matters there — it is what lets an operator set a non-zero + // value — but an absent key resolving to zero is correct rather than a clobber, + // so the two cases need different assertions. + DefaultIsZero bool +} + +// guardedKeys are the GetConfig reads wrapped in viper.IsSet. They are the same +// zero-clobber class app/seidb.go guards, expressed through a different mechanism. +var guardedKeys = []guardedKey{ + {Key: "state-commit.sc-async-commit-buffer", Path: "StateCommit.MemIAVLConfig.AsyncCommitBuffer", Set: 7}, + {Key: "state-commit.sc-keep-recent", Path: "StateCommit.MemIAVLConfig.SnapshotKeepRecent", Set: 9}, + {Key: "state-commit.sc-snapshot-interval", Path: "StateCommit.MemIAVLConfig.SnapshotInterval", Set: 4321}, + {Key: "state-commit.sc-snapshot-min-time-interval", Path: "StateCommit.MemIAVLConfig.SnapshotMinTimeInterval", Set: 11}, + {Key: "state-commit.sc-snapshot-writer-limit", Path: "StateCommit.MemIAVLConfig.SnapshotWriterLimit", Set: 3}, + {Key: "state-commit.sc-snapshot-prefetch-threshold", Path: "StateCommit.MemIAVLConfig.SnapshotPrefetchThreshold", Set: 0.25}, + {Key: "state-commit.flatkv.fsync", Path: "StateCommit.FlatKVConfig.Fsync", Set: true, DefaultIsZero: true}, + { + Key: "state-commit.flatkv.async-write-buffer", Path: "StateCommit.FlatKVConfig.AsyncWriteBuffer", + Set: 5, DefaultIsZero: true, + }, + {Key: "state-commit.flatkv.snapshot-interval", Path: "StateCommit.FlatKVConfig.SnapshotInterval", Set: 777}, + {Key: "state-commit.flatkv.snapshot-keep-recent", Path: "StateCommit.FlatKVConfig.SnapshotKeepRecent", Set: 6}, + { + Key: "state-commit.flatkv.enable-read-write-metrics", Path: "StateCommit.FlatKVConfig.EnableReadWriteMetrics", + Set: true, DefaultIsZero: true, + }, + {Key: "grpc.max-recv-msg-size", Path: "GRPC.MaxRecvMsgSize", Set: 8 * 1024 * 1024}, + {Key: "grpc.max-open-connections", Path: "GRPC.MaxOpenConnections", Set: 123}, + {Key: "grpc-web.max-open-connections", Path: "GRPCWeb.MaxOpenConnections", Set: 456}, +} + +// FuzzGetConfigGuardedKeysPreserveDefaults pins the guarded half of GetConfig: an +// absent key resolves to the in-code default rather than to the zero value viper's +// typed getters would otherwise return. +// +// The keys that matter most are the bounded ones. grpc.max-recv-msg-size, +// grpc.max-open-connections and grpc-web.max-open-connections all default to a +// finite limit, and an unguarded read of an absent key would resolve 0 — which +// gRPC reads as unlimited. A node upgrading with an older app.toml would go from +// bounded to unbounded connections and message sizes with nothing said about it. +func FuzzGetConfigGuardedKeysPreserveDefaults(f *testing.F) { + for i := range len(guardedKeys) { + f.Add(uint(i), false) + f.Add(uint(i), true) + } + + f.Fuzz(func(t *testing.T, keyIdx uint, present bool) { + row := guardedKeys[keyIdx%uint(len(guardedKeys))] + + absent, err := GetConfig(newAppViper(t, nil)) + if err != nil { + t.Fatalf("parsing with no optional keys must succeed, got %v", err) + } + absentLeaf, ok := configtest.LeafAt(configtest.Dump(absent), row.Path) + if !ok { + t.Fatalf("%s resolves into %q, which is not in the parsed config", row.Key, row.Path) + } + + if !present { + // Whether the guard is doing anything is decided by comparing an absent key + // against an explicit zero, not against a synthesized zero literal. A + // synthesized one has to guess the field's Go type, and guessing wrong makes + // the comparison unsatisfiable and the assertion vacuous — which is exactly + // what an int-typed literal did for every uint, uint32 and float64 row here, + // including both gRPC connection bounds. Reading the reader twice needs no + // type knowledge at all. + explicitZero, zeroErr := GetConfig(newAppViper(t, map[string]any{row.Key: 0})) + if zeroErr != nil { + t.Fatalf("%s = 0 must parse, got %v", row.Key, zeroErr) + } + zeroLeaf, ok := configtest.LeafAt(configtest.Dump(explicitZero), row.Path) + if !ok { + t.Fatalf("%s resolves into %q, which is not in the parsed config", row.Key, row.Path) + } + + if row.DefaultIsZero { + if absentLeaf != zeroLeaf { + t.Fatalf("%s is marked DefaultIsZero but an absent key (%s) resolves differently "+ + "from an explicit 0 (%s)", row.Key, absentLeaf, zeroLeaf) + } + return + } + if absentLeaf == zeroLeaf { + t.Fatalf("%s is absent and resolved to the same value as an explicit 0 (%s); the "+ + "guard that preserves the in-code default is gone", row.Key, absentLeaf) + } + return + } + + set, err := GetConfig(newAppViper(t, map[string]any{row.Key: row.Set})) + if err != nil { + t.Fatalf("%s = %#v must parse, got %v", row.Key, row.Set, err) + } + setLeaf, ok := configtest.LeafAt(configtest.Dump(set), row.Path) + if !ok { + t.Fatalf("%s resolves into %q, which is not in the parsed config", row.Key, row.Path) + } + if setLeaf == absentLeaf { + t.Fatalf("%s = %#v did not change the resolved value (%s); the guard admits an "+ + "explicit value as well as protecting an absent one", row.Key, row.Set, setLeaf) + } + }) +} + +// TestGetConfigGuardedKeyDefaultsMatchTheManifest keeps the DefaultIsZero column +// honest in both directions. +// +// It is what stops the guard assertions above from going vacuous. A key marked +// non-zero whose default moves to zero would make its clobber check meaningless; +// a key marked zero whose default becomes non-zero would leave a real clobber +// unchecked. Either way the manifest, not the assertion, is what needs updating. +// +// "Is the default zero" is answered by resolving the key explicitly as 0 and +// comparing, for the same reason the target above does it that way: a literal 0 +// carries Go's int type and would never compare equal to a uint or float64 leaf. +func TestGetConfigGuardedKeyDefaultsMatchTheManifest(t *testing.T) { + absent, err := GetConfig(newAppViper(t, nil)) + if err != nil { + t.Fatalf("GetConfig: %v", err) + } + absentDump := configtest.Dump(absent) + + for _, row := range guardedKeys { + absentLeaf, ok := configtest.LeafAt(absentDump, row.Path) + if !ok { + t.Errorf("%s: %q is not in the parsed config", row.Key, row.Path) + continue + } + explicitZero, zeroErr := GetConfig(newAppViper(t, map[string]any{row.Key: 0})) + if zeroErr != nil { + t.Errorf("%s = 0 must parse, got %v", row.Key, zeroErr) + continue + } + zeroLeaf, ok := configtest.LeafAt(configtest.Dump(explicitZero), row.Path) + if !ok { + t.Errorf("%s: %q is not in the parsed config", row.Key, row.Path) + continue + } + + if isZero := absentLeaf == zeroLeaf; isZero != row.DefaultIsZero { + t.Errorf("%s resolves to %s with no key set and %s with an explicit 0, so "+ + "DefaultIsZero is %v while the manifest says %v; update the row so its guard "+ + "assertion still means something", + row.Key, absentLeaf, zeroLeaf, isZero, row.DefaultIsZero) + } + } +} + +// TestGetConfigGenesisKeyDivergesFromTheAppSideKey records that the two genesis +// parsers read different keys for the same value. GetConfig reads +// genesis.genesis-stream-file; app/genesis.go reads genesis.import-file. Setting +// one leaves the other empty, so a stream-import node configured through the key +// the template renders streams from "". +func TestGetConfigGenesisKeyDivergesFromTheAppSideKey(t *testing.T) { + cfg, err := GetConfig(newAppViper(t, map[string]any{ + "genesis.stream-import": true, + "genesis.import-file": "/var/lib/sei/genesis.json", + })) + if err != nil { + t.Fatalf("GetConfig: %v", err) + } + if !cfg.Genesis.StreamImport { + t.Fatal("genesis.stream-import must resolve; both parsers agree on this key") + } + if cfg.Genesis.GenesisStreamFile != "" { + t.Fatalf("GetConfig read genesis.import-file (%q); it reads genesis-stream-file, and the "+ + "divergence between the two spellings is the pinned behavior", + cfg.Genesis.GenesisStreamFile) + } + + withOwnKey, err := GetConfig(newAppViper(t, map[string]any{ + "genesis.genesis-stream-file": "/var/lib/sei/genesis.json", + })) + if err != nil { + t.Fatalf("GetConfig: %v", err) + } + if withOwnKey.Genesis.GenesisStreamFile != "/var/lib/sei/genesis.json" { + t.Fatalf("genesis.genesis-stream-file resolved to %q", withOwnKey.Genesis.GenesisStreamFile) + } +} + +// TestGetConfigStateStoreReadsAreUnguarded records that GetConfig's [state-store] +// parse has no presence checks, matching app/seidb.go's parseSSConfigs. An absent +// section resolves every field to its zero value, so the reported config says +// ss-enable false and an empty backend on a node whose app.toml simply predates the +// section. +func TestGetConfigStateStoreReadsAreUnguarded(t *testing.T) { + cfg, err := GetConfig(newAppViper(t, nil)) + if err != nil { + t.Fatalf("GetConfig: %v", err) + } + def := config.DefaultStateStoreConfig() + if cfg.StateStore.Enable == def.Enable && def.Enable { + t.Fatal("state-store.ss-enable is no longer clobbered by an absent key; if a guard was " + + "added, GetConfig and parseSSConfigs must be changed together or they will disagree") + } + if cfg.StateStore.Backend == def.Backend && def.Backend != "" { + t.Fatalf("state-store.ss-backend resolved to the default %q from an absent key", cfg.StateStore.Backend) + } + if cfg.StateStore.AsyncWriteBuffer != 0 || cfg.StateStore.KeepRecent != 0 { + t.Fatalf("absent [state-store] must resolve to zeros, got buffer=%d keep-recent=%d", + cfg.StateStore.AsyncWriteBuffer, cfg.StateStore.KeepRecent) + } +} + +// stateSyncKeys is the [state-sync] manifest as GetConfig resolves it. +// +// The section is read three ways and this describes one of them — counted by mechanism, because +// a count of readers goes stale: simd is a fourth call site (sei-ibc-go/testing/simapp/simd/cmd/ +// root.go:273-274) and a second instance of the first mechanism, not a fourth way. NewApp reads +// the same three keys out of an AppOpts and hands them to a baseapp (cmd/seid/cmd/root.go:255 +// and :304-306), which no row can predict. ParseConfig (toml.go:272-277) unmarshals them by mapstructure tag +// over a DefaultConfig base, so an absent snapshot-keep-recent keeps 2 where this reader +// returns 0 — a second describable reader, undescribed. GetConfig reads them as literals +// through one unguarded typed getter each, which is the shape CheckRow holds a reader to. +// +// All three reads are unguarded and one of them clobbers, which is why this section gets no +// CheckAbsent: an empty viper does not resolve to DefaultConfig's [state-sync]. +// TestGetConfigStateSyncReadsAreUnguarded pins that divergence directly. +var stateSyncKeys = []configtest.KeySpec{ + { + Key: "state-sync.snapshot-interval", Path: "SnapshotInterval", + Cast: configtest.CastUint64, Unguarded: true, + Why: "0 is both the in-code default and \"disabled\", so what this row protects is an " + + "explicit interval reaching the field rather than an absent one: it is the only " + + "[state-sync] key with a consumer inside this Config (ValidateBasic, config.go:655), " + + "and a serving node's cadence is NewApp's read (root.go:304)", + }, + { + Key: "state-sync.snapshot-keep-recent", Path: "SnapshotKeepRecent", + Cast: configtest.CastUint32, Unguarded: true, + Why: "the in-code default is 2 and toml.go:78 documents 0 as keep all, so the clobber " + + "inverts the declared retention; what a node retains is NewApp's read (root.go:305)", + }, + { + Key: "state-sync.snapshot-directory", Path: "SnapshotDirectory", + Cast: configtest.CastString, Unguarded: true, + Why: "toml.go:82 documents empty as store under the home directory, and the fallback that " + + "implements it is NewApp's read (root.go:255-257) rather than this one", + }, +} + +// readStateSync drives GetConfig from an AppOpts, which is what lets the manifest engine +// describe a viper-based reader. +// +// The two transports differ by an adapter and not by a wall: newAppViper already takes the +// map[string]any an AppOpts is, so a row's key and raw value reach v.Set and then the same +// typed getter an app.toml value reaches. It returns the section rather than the whole +// Config so a row's Path names its field the way every other section's rows do, which is +// also what lets CheckManifestCoversEveryField point at StateSyncConfig alone. +func readStateSync(t testing.TB) func(configtest.AppOpts) (any, error) { + return func(opts configtest.AppOpts) (any, error) { + cfg, err := GetConfig(newAppViper(t, opts)) + if err != nil { + return nil, err + } + return cfg.StateSync, nil + } +} + +// FuzzGetConfigStateSync drives the [state-sync] manifest. +// +// Two of the three keys had no assertion on a resolved value anywhere in the tree: cmd/seid/cmd +// records snapshot-keep-recent's and snapshot-directory's spelling and cannot predict what NewApp +// does with them, and the whole-Config defaults golden says what DefaultConfig declares rather +// than what a parse returns. snapshot-interval was already held twice — FuzzConfigValidateBasic +// below drives it through GetConfig with a wantErr that is a function of its resolved value, and +// appKeys[5] in cmd/seid/cmd's FuzzApplyPrecedenceApp holds it to a resolved value and Go type +// across every layer combination that sets it — so for that key this target adds arbitrary values +// rather than a first assertion. +func FuzzGetConfigStateSync(f *testing.F) { + read := readStateSync(f) + seeds := configtest.NewSeeds(f, fuzzing.ConfigValue) + + // Rows 0 and 1 get three seeds and row 2 gets two, and only the first seed of each row + // discriminates. An unguarded read resolves an absent key to its cast's zero, and every one + // of these rows resolves to that same zero from an absent key, so a nil seed lands on the + // absent-key value and so does a value the cast rejects: those pin the clobber and, on the + // two numeric rows, the swallowed conversion. Row 2 stops at two because a string cast has + // no malformed input, so there is no swallowed conversion to pin there. A value that + // converts to something else is what states the key is read at all. + seeds.AddRow(uint(0), fuzzing.KindInt64, "", int64(1000), false) + seeds.AddRow(uint(0), fuzzing.KindNil, "", int64(0), false) + seeds.AddRow(uint(0), fuzzing.KindString, "not-a-number", int64(0), false) + seeds.AddRow(uint(1), fuzzing.KindInt64, "", int64(10), false) + seeds.AddRow(uint(1), fuzzing.KindNil, "", int64(0), false) + seeds.AddRow(uint(1), fuzzing.KindString, "not-a-number", int64(0), false) + seeds.AddRow(uint(2), fuzzing.KindString, "/var/lib/sei/snapshots", int64(0), false) + seeds.AddRow(uint(2), fuzzing.KindNil, "", int64(0), false) + + configtest.CheckEveryRowHasADiscriminatingSeed(f, "state-sync", read, stateSyncKeys, seeds) + + f.Fuzz(func(t *testing.T, keyIdx uint, kind uint8, s string, n int64, b bool) { + spec := configtest.Pick(stateSyncKeys, keyIdx) + configtest.CheckRow(t, "state-sync", readStateSync(t), spec, fuzzing.ConfigValue(kind, s, n, b)) + }) +} + +// TestGetConfigStateSyncReadsAreUnguarded records the [state-sync] clobber as a divergence +// rather than as two facts that happen to disagree. +// +// snapshot-keep-recent is a registered flag defaulting to 2 (server/start.go:234) and +// DefaultConfig declares 2, but GetConfig reads it with a bare v.GetUint32, so a viper that +// never saw the key resolves 0 — which toml.go:78 documents as "keep all". That viper is this +// file's layer and not a booted node's: start.go:117 binds the flag in PreRunE, ahead of both +// production calls (start.go:168 and :303), so there the same read takes the flag's 2 whenever +// app.toml is silent. +// +// It is recorded and not repaired, because a characterization PR does not change readers. The +// guard is not hypothetical: ParseConfig already resolves this section that way and returns 2 for +// the absent key this read returns 0 for. The guard would not change the fleet either: with the +// flag bound IsSet is false, so a guarded read falls back to the in-code 2 the unguarded read +// already takes from the flag default. The point of the assertion is that either side moving fails +// it — a guard makes the absent read return 2, and a default moved to 0 makes the clobber stop +// being one — so the divergence can neither close nor widen without a diff. +func TestGetConfigStateSyncReadsAreUnguarded(t *testing.T) { + cfg, err := GetConfig(newAppViper(t, nil)) + if err != nil { + t.Fatalf("GetConfig: %v", err) + } + def := DefaultConfig().StateSync + if def.SnapshotKeepRecent == 0 { + t.Fatal("state-sync.snapshot-keep-recent's in-code default is now 0, so an absent key " + + "clobbers nothing and this recording no longer describes a divergence. If the default " + + "moved deliberately, say what a serving node now retains — 0 is keep-all, not keep-none") + } + if cfg.StateSync.SnapshotKeepRecent != 0 { + t.Fatalf("an absent state-sync.snapshot-keep-recent resolved to %d rather than 0, so the "+ + "read is no longer unguarded. That is a fine end state and a no-op for a booted node, "+ + "which takes 2 from the bound flag either way (start.go:117 and :234); what it changes "+ + "is this recording, so update the row and this assertion in the PR that adds the guard", + cfg.StateSync.SnapshotKeepRecent) + } + if cfg.StateSync.SnapshotInterval != 0 || cfg.StateSync.SnapshotDirectory != "" { + t.Fatalf("absent [state-sync] must resolve to zeros, got interval=%d directory=%q", + cfg.StateSync.SnapshotInterval, cfg.StateSync.SnapshotDirectory) + } +} + +// TestParseConfigAndGetConfigDisagree pins the disagreement between this package's two exported +// readers of [state-sync], which is what the guard above would close. +// +// Handed the same flagless viper, ParseConfig unmarshals over a DefaultConfig base and keeps +// snapshot-keep-recent at 2 while GetConfig's bare v.GetUint32 resolves 0. That is what makes the +// deferral above a deferral rather than an open question: the guard is not a retention anyone has +// to choose, it is ParseConfig's resolution moved into GetConfig, and this states the behavior +// being moved. Nothing else in the tree does — TestParseConfig (config_test.go:358) asserts only +// MinGasPrices. +// +// The disagreement is between the two readers and not between two nodes. GetConfig's production +// calls (start.go:168 and :303) run with the flag bound, where it takes the same 2, and the one +// non-test caller of ParseConfig is server/util.go:308's empty-template branch, which no binary in +// this tree reaches. So what fails when either side moves is the rationale, which is the point. +func TestParseConfigAndGetConfigDisagree(t *testing.T) { + v := newAppViper(t, nil) + parsed, err := ParseConfig(v) + if err != nil { + t.Fatalf("ParseConfig: %v", err) + } + direct, err := GetConfig(v) + if err != nil { + t.Fatalf("GetConfig: %v", err) + } + if parsed.StateSync.SnapshotKeepRecent != 2 || direct.StateSync.SnapshotKeepRecent != 0 { + t.Fatalf("an absent state-sync.snapshot-keep-recent resolved to %d through ParseConfig and "+ + "%d through GetConfig, want 2 and 0. If GetConfig gained the guard the two readers now "+ + "agree, which is the end state — update this test, the row and the deferral note "+ + "together. If ParseConfig stopped resolving the section over DefaultConfig, the deferral "+ + "note above is now wrong about there being an implemented guard to copy, and whoever "+ + "adds one is choosing a retention rather than matching one. A third cause is that the "+ + "in-code default moved off 2, which the defaults golden names and this literal does "+ + "not: a default of 3 reaches here rather than the unguarded-read check, whose guard "+ + "fires only at exactly 0", + parsed.StateSync.SnapshotKeepRecent, direct.StateSync.SnapshotKeepRecent) + } +} + +// TestKeyNamesMatchTheRecordedNames records the [state-sync] spellings GetConfig looks up. +// +// The rows name them as literals, so a rename here fails the row assertions as well; the +// record is what makes the diff name the old and the new operator-facing key rather than a +// resolved value. cmd/seid/cmd holds its own state-sync record for NewApp's two constants, +// and the two files are independent because the readers they describe are. +func TestKeyNamesMatchTheRecordedNames(t *testing.T) { + configtest.CheckKeyNames(t, "state-sync", stateSyncKeys) +} + +// TestManifestNamesEveryField enforces the claim stateSyncKeys makes about itself. +// +// StateSyncConfig is the one struct in this file's surface with a single reader populating +// it, so the check costs no exemptions: a fourth [state-sync] key added to GetConfig fails +// here until it has a row. The [state-commit] and [state-store] structs are shared with +// app/seidb.go and are not assertable this way — see app/config_fuzz_test.go. +func TestManifestNamesEveryField(t *testing.T) { + configtest.CheckManifestCoversEveryField(t, "state-sync", DefaultConfig().StateSync, stateSyncKeys) +} + +// FuzzConfigValidateBasic pins the two conditions that reject an otherwise +// parseable app.toml. +// +// An empty minimum-gas-prices fails, because a validator accepting zero-fee +// transactions is a misconfiguration rather than a choice. And pruning +// "everything" with state-sync snapshots enabled fails, because a node cannot +// serve a snapshot of state it has already pruned. Both are the rare case in this +// surface where a bad combination is refused rather than absorbed. +func FuzzConfigValidateBasic(f *testing.F) { + f.Add("0.01usei", "default", uint64(0)) + f.Add("", "default", uint64(0)) + f.Add("0.01usei", "everything", uint64(100)) + f.Add("0.01usei", "everything", uint64(0)) + f.Add("", "everything", uint64(100)) + f.Add("0.01usei", "nothing", uint64(100)) + + f.Fuzz(func(t *testing.T, minGasPrices, pruning string, snapshotInterval uint64) { + cfg, err := GetConfig(newAppViper(t, map[string]any{ + "minimum-gas-prices": minGasPrices, + "pruning": pruning, + "state-sync.snapshot-interval": snapshotInterval, + })) + if err != nil { + t.Fatalf("GetConfig: %v", err) + } + + wantErr := minGasPrices == "" || (pruning == "everything" && snapshotInterval > 0) + got := cfg.ValidateBasic(nil) + if wantErr && got == nil { + t.Fatalf("min-gas-prices=%q pruning=%q snapshot-interval=%d must fail ValidateBasic", + minGasPrices, pruning, snapshotInterval) + } + if !wantErr && got != nil { + t.Fatalf("min-gas-prices=%q pruning=%q snapshot-interval=%d must pass ValidateBasic, got %v", + minGasPrices, pruning, snapshotInterval, got) + } + }) +} + +// TestDefaultsMatchTheRecordedValues pins the server_config defaults themselves. +// +// The absent-keys coverage in this file proves the reader returns the declared defaults; it +// cannot prove which values those are, because both sides of that comparison come from the +// same package. This compares them against testdata/server_config.golden, an independent +// recording, so a default that moves shows the new value in a diff instead of passing +// silently. +func TestDefaultsMatchTheRecordedValues(t *testing.T) { + // [state-sync] has its own manifest and its own struct, so it gets its own record. The three + // values are also inside server_config.golden, which does catch a change to them, so this is for + // discoverability rather than detection. A reader asking what [state-sync] defaults to reads three + // lines here instead of finding them among two hundred, and the section shows its own defaults + // check in the coverage record. Regenerating one of the two records without the other leaves that + // other one red. + configtest.CheckDefaults(t, "state-sync", DefaultConfig().StateSync) + + configtest.CheckDefaults(t, "server_config", DefaultConfig(), + configtest.DerivedDefault{ + Path: "ConcurrencyWorkers", Want: max(10, min(runtime.NumCPU()*2, 128)), + Why: "max(10, min(runtime.NumCPU()*2, 128))", + }, + ) +} + +// apiKeys covers the [api] keys GetConfig reads. +// +// Every read is a bare viper getter with no IsSet guard (config.go:579-586), so an absent key +// resolves to that getter's zero rather than to what DefaultConfig declares. Unlike [state-sync], +// no api.* flag is registered anywhere in this tree, so nothing supplies a fallback and the zero +// is what a node gets. TestGetConfigAbsentSectionDivergences records which fields that changes. +var apiKeys = []configtest.KeySpec{ + { + Key: "api.enable", Path: "Enable", Cast: configtest.CastBool, Unguarded: true, + Why: "whether the node serves the REST API at all; false is also the declared default, so " + + "this row states the key is read rather than recording a divergence", + }, + { + Key: "api.swagger", Path: "Swagger", Cast: configtest.CastBool, Unguarded: true, + Why: "the declared default is true and an absent key resolves false, so a node whose " + + "app.toml lacks the section serves the API without its documentation", + }, + { + Key: "api.enabled-unsafe-cors", Path: "EnableUnsafeCORS", Cast: configtest.CastBool, + Unguarded: true, + Why: "cross-origin access to the REST API; false either way, so the clobber cannot turn " + + "this on, which is the direction that would matter", + }, + { + Key: "api.address", Path: "Address", Cast: configtest.CastString, Unguarded: true, + Why: "the declared default is tcp://0.0.0.0:1317 and an absent key resolves empty, so the " + + "listener address a node binds comes from the file or from nowhere", + }, + { + Key: "api.max-open-connections", Path: "MaxOpenConnections", Cast: configtest.CastUint, + Unguarded: true, + Why: "the declared default is 1000 and an absent key resolves 0, so the connection ceiling " + + "a node enforces is whichever of those the server treats as a limit", + }, + { + Key: "api.rpc-read-timeout", Path: "RPCReadTimeout", Cast: configtest.CastUint, + Unguarded: true, + Why: "the declared default is 10 seconds and an absent key resolves 0, so a node whose " + + "app.toml lacks the section reads a request body with no deadline", + }, + { + Key: "api.rpc-write-timeout", Path: "RPCWriteTimeout", Cast: configtest.CastUint, + Unguarded: true, + Why: "0 is both the declared default and what an absent key resolves to, so this row " + + "states the key is read rather than recording a divergence", + }, + { + Key: "api.rpc-max-body-bytes", Path: "RPCMaxBodyBytes", Cast: configtest.CastUint, + Unguarded: true, + Why: "the declared default is 1000000 and an absent key resolves 0, so the response body " + + "ceiling a node applies is whichever of those the server treats as a limit", + }, +} + +func readAPI(t testing.TB) func(configtest.AppOpts) (any, error) { + return sectionOfGetConfig(t, func(c Config) any { return c.API }) +} + +// FuzzAPIConfig drives every [api] row. +// +// Three seeds per row, uniformly. Every row here is unguarded, so an absent key, a nil value and a +// value the cast rejects all land on the same zero, and only a value that converts to something else +// states the key is read at all. The nil and malformed pair comes from seedEveryRow, the same shape +// the other five sections use. +func FuzzAPIConfig(f *testing.F) { + read := readAPI(f) + seeds := configtest.NewSeeds(f, fuzzing.ConfigValue) + seedEveryRow(seeds, len(apiKeys)) + + // The discriminating value per row, each chosen away from the value an absent key resolves to, which + // is what CheckEveryRowHasADiscriminatingSeed holds them to. For a bool whose declared default is + // not the zero that is the default itself, since a bool has only the two. + seeds.AddRow(uint(0), fuzzing.KindBool, "", int64(0), true) // enable + seeds.AddRow(uint(1), fuzzing.KindBool, "", int64(0), true) // swagger + seeds.AddRow(uint(2), fuzzing.KindBool, "", int64(0), true) // unsafe CORS + seeds.AddRow(uint(3), fuzzing.KindString, "tcp://127.0.0.1:11317", int64(0), false) + seeds.AddRow(uint(4), fuzzing.KindInt64, "", int64(250), false) // max-open-connections + seeds.AddRow(uint(5), fuzzing.KindInt64, "", int64(30), false) // rpc-read-timeout + seeds.AddRow(uint(6), fuzzing.KindInt64, "", int64(45), false) // rpc-write-timeout + seeds.AddRow(uint(7), fuzzing.KindInt64, "", int64(2000000), false) // rpc-max-body-bytes + + configtest.CheckEveryRowHasADiscriminatingSeed(f, "api", read, apiKeys, seeds) + + f.Fuzz(func(t *testing.T, keyIdx uint, kind uint8, s string, n int64, b bool) { + spec := configtest.Pick(apiKeys, keyIdx) + configtest.CheckRow(t, "api", readAPI(t), spec, fuzzing.ConfigValue(kind, s, n, b)) + }) +} + +// TestAPIKeyNamesMatchTheRecordedNames pins the operator-facing spelling of the eight [api] keys. +func TestAPIKeyNamesMatchTheRecordedNames(t *testing.T) { + configtest.CheckKeyNames(t, "api", apiKeys) +} + +// TestAPIManifestNamesEveryField enforces the claim apiKeys makes about itself, that it names every +// key the reader looks up. +func TestAPIManifestNamesEveryField(t *testing.T) { + configtest.CheckManifestCoversEveryField(t, "api", DefaultConfig().API, apiKeys) +} + +// rosetta covers the [rosetta] keys GetConfig reads. Every read is a bare viper getter +// (config.go:589-594), so an absent key resolves to that getter's zero. +var rosettaKeys = []configtest.KeySpec{ + { + Key: "rosetta.enable", Path: "Enable", Cast: configtest.CastBool, Unguarded: true, + Why: "whether the node serves the Rosetta API; false either way, so this row states the " + + "key is read rather than recording a divergence", + }, + { + Key: "rosetta.address", Path: "Address", Cast: configtest.CastString, Unguarded: true, + Why: "the declared default is :8080 and an absent key resolves empty, so the listener " + + "address comes from the file or from nowhere", + }, + { + Key: "rosetta.blockchain", Path: "Blockchain", Cast: configtest.CastString, Unguarded: true, + Why: "the declared default is app and an absent key resolves empty, so the blockchain name " + + "Rosetta reports identifies nothing", + }, + { + Key: "rosetta.network", Path: "Network", Cast: configtest.CastString, Unguarded: true, + Why: "the declared default is network and an absent key resolves empty, so the network name " + + "Rosetta reports identifies nothing", + }, + { + Key: "rosetta.retries", Path: "Retries", Cast: configtest.CastInt, Unguarded: true, + Why: "the declared default is 3 and an absent key resolves 0, so a node retries a failed " + + "Rosetta operation as many times as its file says and no more", + }, + { + Key: "rosetta.offline", Path: "Offline", Cast: configtest.CastBool, Unguarded: true, + Why: "whether Rosetta runs without a live node; false either way, so this row states the " + + "key is read rather than recording a divergence", + }, +} + +// grpcWebKeys covers the [grpc-web] keys GetConfig reads. +// +// Three of the four are bare getters. max-open-connections is not: config.go:514-517 reads it +// behind v.IsSet and falls back to the in-code default, with a comment saying the guard is there so +// a node upgrading with an older app.toml stays bounded. That is the same hazard +// api.max-open-connections and api.rpc-max-body-bytes carry unguarded, which is why this section is +// worth reading beside apiKeys rather than on its own. +var grpcWebKeys = []configtest.KeySpec{ + { + Key: "grpc-web.enable", Path: "Enable", Cast: configtest.CastBool, Unguarded: true, + Why: "the declared default is true and an absent key resolves false, so a node whose " + + "app.toml lacks the section serves no gRPC-Web", + }, + { + Key: "grpc-web.address", Path: "Address", Cast: configtest.CastString, Unguarded: true, + Why: "the declared default is 0.0.0.0:9091 and an absent key resolves empty", + }, + { + Key: "grpc-web.enable-unsafe-cors", Path: "EnableUnsafeCORS", Cast: configtest.CastBool, + Unguarded: true, + Why: "cross-origin access to gRPC-Web; false either way, so the clobber cannot turn this " + + "on, which is the direction that would matter", + }, + { + Key: "grpc-web.max-open-connections", Path: "MaxOpenConnections", Cast: configtest.CastUint, + Why: "the one guarded read in this section (config.go:514-517), so an absent key keeps the " + + "declared 1000 rather than resolving 0; the guard exists so an upgrading node stays bounded", + }, +} + +// telemetryKeys covers the [telemetry] keys GetConfig reads as scalars. +// +// global-labels is not a row. It is read as a bare type assertion whose absence fails GetConfig +// outright and whose shape rules are their own subject, so it has dedicated targets above +// (FuzzGetConfigGlobalLabels, TestGetConfigRequiresGlobalLabels, TestGetConfigPanicsOnNonStringLabel) +// and is recorded by name rather than driven as a row. +var telemetryKeys = []configtest.KeySpec{ + { + Key: "telemetry.service-name", Path: "ServiceName", Cast: configtest.CastString, + Unguarded: true, + Why: "empty either way, so this row states the key is read rather than recording a divergence", + }, + { + Key: "telemetry.enabled", Path: "Enabled", Cast: configtest.CastBool, Unguarded: true, + Why: "the declared default is true and an absent key resolves false, so a node whose " + + "app.toml lacks the section emits no telemetry", + }, + { + Key: "telemetry.enable-hostname", Path: "EnableHostname", Cast: configtest.CastBool, + Unguarded: true, + Why: "false either way, so this row states the key is read", + }, + { + Key: "telemetry.enable-hostname-label", Path: "EnableHostnameLabel", + Cast: configtest.CastBool, Unguarded: true, + Why: "false either way, so this row states the key is read", + }, + { + Key: "telemetry.enable-service-label", Path: "EnableServiceLabel", Cast: configtest.CastBool, + Unguarded: true, + Why: "false either way, so this row states the key is read", + }, + { + Key: "telemetry.prometheus-retention-time", Path: "PrometheusRetentionTime", + Cast: configtest.CastInt64, Unguarded: true, + Why: "the declared default is 7200 seconds and an absent key resolves 0, which telemetry " + + "reads as retaining nothing, so a scrape finds an empty store", + }, +} + +// telemetryKeysWithTargetsOfTheirOwn is global-labels, recorded for its name because its behaviour +// is driven by targets rather than by a row. +var telemetryKeysWithTargetsOfTheirOwn = []configtest.KeyName{"telemetry.global-labels"} + +func readRosetta(t testing.TB) func(configtest.AppOpts) (any, error) { + return sectionOfGetConfig(t, func(c Config) any { return c.Rosetta }) +} + +func readGRPCWeb(t testing.TB) func(configtest.AppOpts) (any, error) { + return sectionOfGetConfig(t, func(c Config) any { return c.GRPCWeb }) +} + +func readTelemetry(t testing.TB) func(configtest.AppOpts) (any, error) { + return sectionOfGetConfig(t, func(c Config) any { return c.Telemetry }) +} + +// sectionOfGetConfig adapts GetConfig to the reader shape the checks take, for one section. +// +// One helper rather than a function per section, because every one of these differs only in which +// field it returns, and a per-section copy is a place for the newAppViper call to drift. +func sectionOfGetConfig(t testing.TB, section func(Config) any) func(configtest.AppOpts) (any, error) { + return func(opts configtest.AppOpts) (any, error) { + cfg, err := GetConfig(newAppViper(t, opts)) + if err != nil { + return nil, err + } + return section(cfg), nil + } +} + +// seedEveryRow gives each row a nil and a malformed seed, which is the pair every unguarded section +// needs so an ordinary go test run reaches the clobber and the swallowed conversion. +func seedEveryRow(seeds *configtest.Seeds, rows int) { + for i := range rows { + seeds.AddRow(uint(i), fuzzing.KindNil, "", int64(0), false) + seeds.AddRow(uint(i), fuzzing.KindString, "not-a-value", int64(0), false) + } +} + +func FuzzRosettaConfig(f *testing.F) { + seeds := configtest.NewSeeds(f, fuzzing.ConfigValue) + seedEveryRow(seeds, len(rosettaKeys)) + + // One discriminating value per row, away from the value an absent key resolves to. + seeds.AddRow(uint(0), fuzzing.KindBool, "", int64(0), true) + seeds.AddRow(uint(1), fuzzing.KindString, ":18080", int64(0), false) + seeds.AddRow(uint(2), fuzzing.KindString, "sei-app", int64(0), false) + seeds.AddRow(uint(3), fuzzing.KindString, "sei-network", int64(0), false) + seeds.AddRow(uint(4), fuzzing.KindInt64, "", int64(9), false) + seeds.AddRow(uint(5), fuzzing.KindBool, "", int64(0), true) + + configtest.CheckEveryRowHasADiscriminatingSeed(f, "rosetta", readRosetta(f), rosettaKeys, seeds) + + f.Fuzz(func(t *testing.T, keyIdx uint, kind uint8, s string, n int64, b bool) { + spec := configtest.Pick(rosettaKeys, keyIdx) + configtest.CheckRow(t, "rosetta", readRosetta(t), spec, fuzzing.ConfigValue(kind, s, n, b)) + }) +} + +func FuzzGRPCWebConfig(f *testing.F) { + seeds := configtest.NewSeeds(f, fuzzing.ConfigValue) + seedEveryRow(seeds, len(grpcWebKeys)) + + seeds.AddRow(uint(0), fuzzing.KindBool, "", int64(0), true) + seeds.AddRow(uint(1), fuzzing.KindString, "127.0.0.1:19091", int64(0), false) + seeds.AddRow(uint(2), fuzzing.KindBool, "", int64(0), true) + seeds.AddRow(uint(3), fuzzing.KindInt64, "", int64(250), false) + + configtest.CheckEveryRowHasADiscriminatingSeed(f, "grpc-web", readGRPCWeb(f), grpcWebKeys, seeds) + + f.Fuzz(func(t *testing.T, keyIdx uint, kind uint8, s string, n int64, b bool) { + spec := configtest.Pick(grpcWebKeys, keyIdx) + configtest.CheckRow(t, "grpc-web", readGRPCWeb(t), spec, fuzzing.ConfigValue(kind, s, n, b)) + }) +} + +func FuzzTelemetryConfig(f *testing.F) { + seeds := configtest.NewSeeds(f, fuzzing.ConfigValue) + seedEveryRow(seeds, len(telemetryKeys)) + + seeds.AddRow(uint(0), fuzzing.KindString, "sei-node", int64(0), false) + seeds.AddRow(uint(1), fuzzing.KindBool, "", int64(0), true) + seeds.AddRow(uint(2), fuzzing.KindBool, "", int64(0), true) + seeds.AddRow(uint(3), fuzzing.KindBool, "", int64(0), true) + seeds.AddRow(uint(4), fuzzing.KindBool, "", int64(0), true) + seeds.AddRow(uint(5), fuzzing.KindInt64, "", int64(3600), false) + + configtest.CheckEveryRowHasADiscriminatingSeed(f, "telemetry", readTelemetry(f), telemetryKeys, + seeds, telemetryKeysWithTargetsOfTheirOwn...) + + f.Fuzz(func(t *testing.T, keyIdx uint, kind uint8, s string, n int64, b bool) { + spec := configtest.Pick(telemetryKeys, keyIdx) + configtest.CheckRow(t, "telemetry", readTelemetry(t), spec, fuzzing.ConfigValue(kind, s, n, b)) + }) +} + +func TestRosettaKeyNamesMatchTheRecordedNames(t *testing.T) { + configtest.CheckKeyNames(t, "rosetta", rosettaKeys) +} + +func TestGRPCWebKeyNamesMatchTheRecordedNames(t *testing.T) { + configtest.CheckKeyNames(t, "grpc-web", grpcWebKeys) +} + +func TestTelemetryKeyNamesMatchTheRecordedNames(t *testing.T) { + configtest.CheckKeyNames(t, "telemetry", telemetryKeys, telemetryKeysWithTargetsOfTheirOwn...) +} + +func TestRosettaManifestNamesEveryField(t *testing.T) { + configtest.CheckManifestCoversEveryField(t, "rosetta", DefaultConfig().Rosetta, rosettaKeys) +} + +func TestGRPCWebManifestNamesEveryField(t *testing.T) { + configtest.CheckManifestCoversEveryField(t, "grpc-web", DefaultConfig().GRPCWeb, grpcWebKeys) +} + +func TestTelemetryManifestNamesEveryField(t *testing.T) { + configtest.CheckManifestCoversEveryField(t, "telemetry", DefaultConfig().Telemetry, telemetryKeys, + // FuzzGetConfigGlobalLabels drives this field; it is not a plain guarded cast. + "GlobalLabels", + ) +} + +// TestGetConfigAbsentSectionDivergences records every field these sections resolve away from its +// declared default when the section is absent from app.toml. +// +// One table for all of them, because the divergence is one property and a reader comparing sections +// wants them side by side. It puts api.max-open-connections and api.rpc-max-body-bytes beside the +// guarded grpc-web.max-open-connections, which is the contrast worth seeing. +// +// The diverges column asserts both directions, so a key is anchored whether or not it moves today. A +// declared default later shifting onto the getter's zero, or off it, fails here rather than changing +// the divergence set quietly. The rows set false are the keys whose declared default already equals +// that zero. +// +// Every plain cast across the six sections is here. None of these sections is wired to CheckAbsent, +// so this table is the only thing tying their absent-key resolution to their declared defaults. +// +// Compared with reflect.DeepEqual rather than !=, because != on two any values panics rather than +// reporting when either side holds a slice or a map. index-events already holds a []string, and a +// field type changing to one later would otherwise turn this table into a panic. +func TestGetConfigAbsentSectionDivergences(t *testing.T) { + cfg, err := GetConfig(newAppViper(t, nil)) + if err != nil { + t.Fatalf("GetConfig: %v", err) + } + def := DefaultConfig() + + covered := map[string]bool{} + + for _, c := range []struct { + key string + absent, declared any + diverges bool + }{ + {"rosetta.address", cfg.Rosetta.Address, def.Rosetta.Address, true}, + {"rosetta.blockchain", cfg.Rosetta.Blockchain, def.Rosetta.Blockchain, true}, + {"rosetta.network", cfg.Rosetta.Network, def.Rosetta.Network, true}, + {"rosetta.retries", cfg.Rosetta.Retries, def.Rosetta.Retries, true}, + {"grpc-web.enable", cfg.GRPCWeb.Enable, def.GRPCWeb.Enable, true}, + {"grpc-web.address", cfg.GRPCWeb.Address, def.GRPCWeb.Address, true}, + // The [grpc] keys read as plain casts. keepalive-permit-without-stream is the third, further + // down with the other rows whose default is already the zero. Its remaining eight are guarded + // or clamped and held by TestGetConfigGRPCAbsentReads. + {"grpc.enable", cfg.GRPC.Enable, def.GRPC.Enable, true}, + {"grpc.address", cfg.GRPC.Address, def.GRPC.Address, true}, + {"telemetry.enabled", cfg.Telemetry.Enabled, def.Telemetry.Enabled, true}, + { + "telemetry.prometheus-retention-time", + cfg.Telemetry.PrometheusRetentionTime, def.Telemetry.PrometheusRetentionTime, true, + }, + + // [api]. Five diverge. The three set false have a declared default that is already the + // getter's zero, so nothing about the resolved value distinguishes a guard from its absence. + {"api.swagger", cfg.API.Swagger, def.API.Swagger, true}, + {"api.address", cfg.API.Address, def.API.Address, true}, + {"api.max-open-connections", cfg.API.MaxOpenConnections, def.API.MaxOpenConnections, true}, + {"api.rpc-read-timeout", cfg.API.RPCReadTimeout, def.API.RPCReadTimeout, true}, + {"api.rpc-max-body-bytes", cfg.API.RPCMaxBodyBytes, def.API.RPCMaxBodyBytes, true}, + {"api.enable", cfg.API.Enable, def.API.Enable, false}, + {"api.enabled-unsafe-cors", cfg.API.EnableUnsafeCORS, def.API.EnableUnsafeCORS, false}, + {"api.rpc-write-timeout", cfg.API.RPCWriteTimeout, def.API.RPCWriteTimeout, false}, + + // The top-level keys, written with no section header. occ-enabled resolving false runs a node + // without optimistic concurrency control, and minimum-gas-prices resolving empty is the + // spelling for accepting a transaction at any fee. + {"minimum-gas-prices", cfg.MinGasPrices, def.MinGasPrices, true}, + {"inter-block-cache", cfg.InterBlockCache, def.InterBlockCache, true}, + {"pruning", cfg.Pruning, def.Pruning, true}, + {"pruning-keep-recent", cfg.PruningKeepRecent, def.PruningKeepRecent, true}, + {"pruning-interval", cfg.PruningInterval, def.PruningInterval, true}, + {"concurrency-workers", cfg.ConcurrencyWorkers, def.ConcurrencyWorkers, true}, + {"occ-enabled", cfg.OccEnabled, def.OccEnabled, true}, + {"halt-height", cfg.HaltHeight, def.HaltHeight, false}, + {"freeze-height", cfg.FreezeHeight, def.FreezeHeight, false}, + {"halt-time", cfg.HaltTime, def.HaltTime, false}, + {"min-retain-blocks", cfg.MinRetainBlocks, def.MinRetainBlocks, false}, + {"compaction-interval", cfg.CompactionInterval, def.CompactionInterval, false}, + + // The remaining plain casts. Every row from here down has a declared default equal to its + // getter's zero, so none diverges today, and each is here for the reason api.enable is. This + // table is the only thing tying these sections' absent-key resolution to their declared + // defaults, since none of them is wired to CheckAbsent. + {"rosetta.enable", cfg.Rosetta.Enable, def.Rosetta.Enable, false}, + {"rosetta.offline", cfg.Rosetta.Offline, def.Rosetta.Offline, false}, + {"grpc-web.enable-unsafe-cors", cfg.GRPCWeb.EnableUnsafeCORS, def.GRPCWeb.EnableUnsafeCORS, false}, + { + "grpc.keepalive-permit-without-stream", + cfg.GRPC.KeepalivePermitWithoutStream, def.GRPC.KeepalivePermitWithoutStream, false, + }, + {"telemetry.service-name", cfg.Telemetry.ServiceName, def.Telemetry.ServiceName, false}, + {"telemetry.enable-hostname", cfg.Telemetry.EnableHostname, def.Telemetry.EnableHostname, false}, + { + "telemetry.enable-hostname-label", + cfg.Telemetry.EnableHostnameLabel, def.Telemetry.EnableHostnameLabel, false, + }, + { + "telemetry.enable-service-label", + cfg.Telemetry.EnableServiceLabel, def.Telemetry.EnableServiceLabel, false, + }, + + // index-events resolves to a []string, which is why the comparison is reflect.DeepEqual rather + // than !=. Both sides are nil today, so it is a false row. + {"index-events", cfg.IndexEvents, def.IndexEvents, false}, + // The guarded read. Its absent value is the declared default, which is the property the + // guard exists to provide. + { + "grpc-web.max-open-connections", + cfg.GRPCWeb.MaxOpenConnections, def.GRPCWeb.MaxOpenConnections, false, + }, + } { + covered[c.key] = true + if got := !reflect.DeepEqual(c.absent, c.declared); got != c.diverges { + verb := "no longer diverges from" + if !c.diverges { + verb = "now diverges from" + } + t.Errorf("%s %s its declared default: absent=%v declared=%v. If a guard was added or "+ + "removed, or a default moved onto the getter's zero, update the row and this table "+ + "in the same PR", c.key, verb, c.absent, c.declared) + } + } + + requireEveryManifestRowIsAnchored(t, covered) +} + +// requireEveryManifestRowIsAnchored holds the table above to the manifests it is meant to anchor. +// +// The rows are hand-listed, because the diverges column is a judgement about each key that nothing +// derives. What can be derived is which keys need a row at all, and this does that: a key added to any +// of the six manifests gets a row, a seed and a name record from the checks already wired, and would +// otherwise get no absent-key entry while this table is stated to be the only thing tying these +// sections to their declared defaults. +func requireEveryManifestRowIsAnchored(t *testing.T, covered map[string]bool) { + t.Helper() + + var missing []string + for _, manifest := range [][]configtest.KeySpec{ + apiKeys, rosettaKeys, grpcWebKeys, telemetryKeys, grpcKeys, baseConfigKeys, + } { + for _, spec := range manifest { + if !covered[spec.Key] { + missing = append(missing, spec.Key) + } + } + } + if len(missing) > 0 { + sort.Strings(missing) + t.Errorf("these manifest rows have no entry in the absent-key table above, so nothing ties "+ + "their absent-key resolution to the declared defaults:\n %s\n"+ + "Add a row with the diverges value the key actually has, which is a decision rather than "+ + "something this can fill in.", strings.Join(missing, "\n ")) + } +} + +// baseConfigKeys covers the thirteen keys GetConfig reads at the top level of app.toml, the ones +// written without a section header. FreezeHeight is checked; the other fields use bare viper getters. +var baseConfigKeys = []configtest.KeySpec{ + { + Key: "minimum-gas-prices", Path: "MinGasPrices", Cast: configtest.CastString, + Unguarded: true, + Why: "the declared default is 0.01usei and an absent key resolves empty, which is the " + + "spelling for accepting a transaction at any fee", + }, + { + Key: "inter-block-cache", Path: "InterBlockCache", Cast: configtest.CastBool, + Unguarded: true, + Why: "the declared default is true and an absent key resolves false, so the node reads " + + "every store access from disk", + }, + { + Key: "pruning", Path: "Pruning", Cast: configtest.CastString, Unguarded: true, + Why: "the declared default is nothing, meaning keep all history, and an absent key resolves " + + "empty, which is not one of the strategy names", + }, + { + Key: "pruning-keep-recent", Path: "PruningKeepRecent", Cast: configtest.CastString, + Unguarded: true, + Why: "the declared default is the string 0 and an absent key resolves empty", + }, + { + Key: "pruning-interval", Path: "PruningInterval", Cast: configtest.CastString, + Unguarded: true, + Why: "the declared default is the string 0 and an absent key resolves empty", + }, + { + Key: "halt-height", Path: "HaltHeight", Cast: configtest.CastUint64, Unguarded: true, + Why: "0 is both the declared default and the spelling for never halting, so this row states " + + "the key is read rather than recording a divergence", + }, + { + Key: "halt-time", Path: "HaltTime", Cast: configtest.CastUint64, Unguarded: true, + Why: "0 is both the declared default and the spelling for never halting", + }, + { + Key: "index-events", Path: "IndexEvents", Cast: configtest.CastStringSlice, Unguarded: true, + Why: "which events the node indexes; nil either way, and the only slice-cast row here, so " + + "it is where a value the cast turns into a one-element slice would show up", + }, + { + Key: "min-retain-blocks", Path: "MinRetainBlocks", Cast: configtest.CastUint64, + Unguarded: true, + Why: "0 is both the declared default and the spelling for retaining everything", + }, + { + Key: "compaction-interval", Path: "CompactionInterval", Cast: configtest.CastUint64, + Unguarded: true, + Why: "0 is both the declared default and the spelling for never compacting", + }, + { + Key: "concurrency-workers", Path: "ConcurrencyWorkers", Cast: configtest.CastInt, + Unguarded: true, + Why: "the declared default is derived from the machine and an absent key resolves 0, so the " + + "worker count a node runs with comes from the file or is nothing", + }, + { + Key: "occ-enabled", Path: "OccEnabled", Cast: configtest.CastBool, Unguarded: true, + Why: "the declared default is true and an absent key resolves false, so a node whose " + + "app.toml lacks the key executes without optimistic concurrency control", + }, + { + Key: "freeze-height", Path: "FreezeHeight", Cast: configtest.CastUint64, Unguarded: true, Checked: true, + Why: "0 is both the declared default and the spelling for allowing consensus to advance", + }, +} + +func readBaseConfig(t testing.TB) func(configtest.AppOpts) (any, error) { + return sectionOfGetConfig(t, func(c Config) any { return c.BaseConfig }) +} + +func FuzzBaseConfig(f *testing.F) { + seeds := configtest.NewSeeds(f, fuzzing.ConfigValue) + seedEveryRow(seeds, len(baseConfigKeys)) + + // One discriminating value per row, away from the value an absent key resolves to. + seeds.AddRow(uint(0), fuzzing.KindString, "0.5usei", int64(0), false) + seeds.AddRow(uint(1), fuzzing.KindBool, "", int64(0), true) + seeds.AddRow(uint(2), fuzzing.KindString, "everything", int64(0), false) + seeds.AddRow(uint(3), fuzzing.KindString, "500", int64(0), false) + seeds.AddRow(uint(4), fuzzing.KindString, "17", int64(0), false) + seeds.AddRow(uint(5), fuzzing.KindInt64, "", int64(9000000), false) + seeds.AddRow(uint(6), fuzzing.KindInt64, "", int64(1893456000), false) + seeds.AddRow(uint(7), fuzzing.KindString, "message.action", int64(0), false) + seeds.AddRow(uint(8), fuzzing.KindInt64, "", int64(200000), false) + seeds.AddRow(uint(9), fuzzing.KindInt64, "", int64(1000), false) + seeds.AddRow(uint(10), fuzzing.KindInt64, "", int64(7), false) + seeds.AddRow(uint(11), fuzzing.KindBool, "", int64(0), true) + seeds.AddRow(uint(12), fuzzing.KindInt64, "", int64(9000000), false) + seeds.AddRow(uint(12), fuzzing.KindInt64, "", int64(-1), false) + + configtest.CheckEveryRowHasADiscriminatingSeed(f, "base_config", readBaseConfig(f), + baseConfigKeys, seeds) + + f.Fuzz(func(t *testing.T, keyIdx uint, kind uint8, s string, n int64, b bool) { + spec := configtest.Pick(baseConfigKeys, keyIdx) + configtest.CheckRow(t, "base_config", readBaseConfig(t), spec, + fuzzing.ConfigValue(kind, s, n, b)) + }) +} + +func TestBaseConfigKeyNamesMatchTheRecordedNames(t *testing.T) { + configtest.CheckKeyNames(t, "base_config", baseConfigKeys) +} + +// TestBaseConfigManifestNamesEveryField enforces the manifest's claim, and records the one field +// that has no key. +// +// PruningKeepEvery carries a mapstructure tag of pruning-keep-every and a declared default of "0", +// and GetConfig never reads it. So no app.toml value reaches it through this reader, and the +// exemption below is the record of that rather than a gap in the manifest. It is the shape of thing +// a replacement manager would otherwise try to map a key onto. +func TestBaseConfigManifestNamesEveryField(t *testing.T) { + configtest.CheckManifestCoversEveryField(t, "base_config", DefaultConfig().BaseConfig, + baseConfigKeys, + "PruningKeepEvery", + ) +} + +// grpcKeys covers the three [grpc] keys read as plain casts. +// +// The section is where the guarding in this reader is most complete, which is why only three keys +// are rows. Eight others are read behind v.IsSet or through clampNonNegativeDuration and so resolve +// an absent key to the in-code default rather than to a zero; CheckRow would predict the wrong +// resolution for each, so they are driven by FuzzGetConfigGRPCDurationClamps and +// TestGetConfigGRPCAbsentReads and recorded by name below. +// +// Read this beside apiKeys. Both sections expose a listener with a connection ceiling and a message +// size ceiling, and here every ceiling is guarded while there none is. +var grpcKeys = []configtest.KeySpec{ + { + Key: "grpc.enable", Path: "Enable", Cast: configtest.CastBool, Unguarded: true, + Why: "the declared default is true and an absent key resolves false, so a node whose " + + "app.toml lacks the section serves no gRPC", + }, + { + Key: "grpc.address", Path: "Address", Cast: configtest.CastString, Unguarded: true, + Why: "the declared default is 0.0.0.0:9090 and an absent key resolves empty", + }, + { + Key: "grpc.keepalive-permit-without-stream", Path: "KeepalivePermitWithoutStream", + Cast: configtest.CastBool, Unguarded: true, + Why: "whether a client may ping with no active stream; false either way, so this row states " + + "the key is read rather than recording a divergence", + }, +} + +// grpcKeysWithTargetsOfTheirOwn are the [grpc] keys whose resolution a row cannot describe, recorded +// for their names alone. +// +// Six are read behind v.IsSet, so an absent key keeps the in-code default. The other two, +// max-connection-age and max-connection-age-grace, are read unconditionally through the clamp +// (config.go:551-552), and their absent value matches the declared default only because both +// defaults are 0. The clamp rescues a negative value and does nothing for an absent one, so they are +// unguarded reads whose clobber is invisible. TestGetConfigGRPCAbsentReads holds the two groups +// apart for that reason. +// +// What the record adds is narrower than it looks, and worth stating exactly. Each of these keys is a +// literal at its read site, so renaming one already reddens its clamp target. The record puts the +// operator-facing spelling in a reviewable diff, and it is what would catch the rename if any of +// these moved to a shared constant the way twenty-eight of app's thirty rows have, since then the +// row and the read site would move together and the behavioural target would stay green. +var grpcKeysWithTargetsOfTheirOwn = []configtest.KeyName{ + "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", +} + +func readGRPC(t testing.TB) func(configtest.AppOpts) (any, error) { + return sectionOfGetConfig(t, func(c Config) any { return c.GRPC }) +} + +func FuzzGRPCConfig(f *testing.F) { + seeds := configtest.NewSeeds(f, fuzzing.ConfigValue) + seedEveryRow(seeds, len(grpcKeys)) + + seeds.AddRow(uint(0), fuzzing.KindBool, "", int64(0), true) + seeds.AddRow(uint(1), fuzzing.KindString, "127.0.0.1:19090", int64(0), false) + seeds.AddRow(uint(2), fuzzing.KindBool, "", int64(0), true) + + configtest.CheckEveryRowHasADiscriminatingSeed(f, "grpc", readGRPC(f), grpcKeys, seeds, + grpcKeysWithTargetsOfTheirOwn...) + + f.Fuzz(func(t *testing.T, keyIdx uint, kind uint8, s string, n int64, b bool) { + spec := configtest.Pick(grpcKeys, keyIdx) + configtest.CheckRow(t, "grpc", readGRPC(t), spec, fuzzing.ConfigValue(kind, s, n, b)) + }) +} + +// TestGRPCKeyNamesMatchTheRecordedNames pins all eleven [grpc] key names, the three rows and the +// eight driven elsewhere. +// +// The eight had no record before this, because their target carries a local struct rather than a +// KeySpec table, so nothing held their spelling. That is the gap this closes. +func TestGRPCKeyNamesMatchTheRecordedNames(t *testing.T) { + configtest.CheckKeyNames(t, "grpc", grpcKeys, grpcKeysWithTargetsOfTheirOwn...) +} + +func TestGRPCManifestNamesEveryField(t *testing.T) { + configtest.CheckManifestCoversEveryField(t, "grpc", DefaultConfig().GRPC, grpcKeys, + // Guarded reads, so an absent key keeps the in-code default rather than clobbering it. + "MaxRecvMsgSize", + "MaxOpenConnections", + // Clamped reads: a negative resolves to the in-code default rather than passing through. + "MaxConnectionIdle", + "MaxConnectionAge", + "MaxConnectionAgeGrace", + "KeepaliveTime", + "KeepaliveTimeout", + "KeepaliveMinTime", + ) +} + +// TestGetConfigGRPCAbsentReads records what an absent [grpc] key resolves to, holding the guarded +// reads apart from the two that only look guarded. +// +// This is the assertion the exemptions in TestGRPCManifestNamesEveryField rest on. Without it that +// list would claim those fields are covered elsewhere with nothing checking it, and a guard removed +// from any of them would pass every check in this file. +// +// The split matters because the two groups fail for different reasons and a reader needs the right +// one. Six keys are read behind v.IsSet, so a guard is what returns the declared default and losing +// it is the failure. max-connection-age and max-connection-age-grace have no guard at all: they are +// read unconditionally and clamped, so an absent key resolves 0 and that happens to equal their +// declared default. Moving either default off 0 turns them into a visible clobber, which is a +// different event from a guard disappearing. +func TestGetConfigGRPCAbsentReads(t *testing.T) { + cfg, err := GetConfig(newAppViper(t, nil)) + if err != nil { + t.Fatalf("GetConfig: %v", err) + } + def := DefaultConfig().GRPC + got := cfg.GRPC + + // Read behind v.IsSet, so the guard is what restores the declared default. + for _, c := range []struct { + key string + absent, declared any + }{ + {"grpc.max-recv-msg-size", got.MaxRecvMsgSize, def.MaxRecvMsgSize}, + {"grpc.max-open-connections", got.MaxOpenConnections, def.MaxOpenConnections}, + {"grpc.max-connection-idle", got.MaxConnectionIdle, def.MaxConnectionIdle}, + {"grpc.keepalive-time", got.KeepaliveTime, def.KeepaliveTime}, + {"grpc.keepalive-timeout", got.KeepaliveTimeout, def.KeepaliveTimeout}, + {"grpc.keepalive-min-time", got.KeepaliveMinTime, def.KeepaliveMinTime}, + } { + if c.absent != c.declared { + t.Errorf("an absent %s resolved to %v rather than the declared %v, so its v.IsSet guard "+ + "is gone. That is the failure the guard exists to prevent, and config.go:519-521 says "+ + "why: a node upgrading with an older app.toml stays bounded", c.key, c.absent, c.declared) + } + } + + // Read unconditionally and clamped. Nothing guards these, so the assertion is on the coincidence + // itself: the declared default is the getter's zero, which is why an absent key looks correct. + for _, c := range []struct { + key string + absent time.Duration + declared time.Duration + }{ + {"grpc.max-connection-age", got.MaxConnectionAge, def.MaxConnectionAge}, + {"grpc.max-connection-age-grace", got.MaxConnectionAgeGrace, def.MaxConnectionAgeGrace}, + } { + if c.declared != 0 { + t.Errorf("%s's declared default is now %v rather than 0. It is read unconditionally and "+ + "only clamped, so an absent key still resolves 0, which is now a clobber the manifest "+ + "should carry as a row rather than an exemption", c.key, c.declared) + continue + } + if c.absent != 0 { + t.Errorf("an absent %s resolved to %v rather than 0. That means a guard was added or the "+ + "clamp changed, which is a fine end state and moves this key into the guarded group "+ + "above", c.key, c.absent) + } + } +} + +// TestWiringMatchesTheRecord pins which checks each of this package's sections is wired to. +// +// Every other check here reports a change to what it asserts. None reports a check being removed, so +// this records the wiring and fails when it thins out. +func TestWiringMatchesTheRecord(t *testing.T) { + configtest.CheckWiring(t) +} diff --git a/sei-cosmos/server/config/config_test.go b/sei-cosmos/server/config/config_test.go index 9d0951a32b..849fb521ed 100644 --- a/sei-cosmos/server/config/config_test.go +++ b/sei-cosmos/server/config/config_test.go @@ -2,6 +2,11 @@ package config import ( "bytes" +<<<<<<< HEAD +======= + "math" + "strings" +>>>>>>> 20eb288 (Add freeze mode for historical EVM RPC (#3910)) "testing" tmcfg "github.com/sei-protocol/sei-chain/sei-tendermint/config" @@ -127,6 +132,35 @@ func TestValidateBasic(t *testing.T) { }, expectErr: true, }, + { + name: "freeze height above maximum int64", + setupCfg: func() *Config { + cfg := DefaultConfig() + cfg.FreezeHeight = uint64(math.MaxInt64) + 1 + return cfg + }, + expectErr: true, + }, + { + name: "freeze and halt heights", + setupCfg: func() *Config { + cfg := DefaultConfig() + cfg.FreezeHeight = 100 + cfg.HaltHeight = 100 + return cfg + }, + expectErr: true, + }, + { + name: "freeze height and halt time", + setupCfg: func() *Config { + cfg := DefaultConfig() + cfg.FreezeHeight = 100 + cfg.HaltTime = 100 + return cfg + }, + expectErr: true, + }, } for _, tt := range tests { @@ -142,6 +176,14 @@ func TestValidateBasic(t *testing.T) { } } +func TestGetConfigRejectsNegativeFreezeHeight(t *testing.T) { + v := seedViperWithDefaultConfig(t) + v.Set("freeze-height", -1) + + _, err := GetConfig(v) + require.Error(t, err) +} + func TestGetMinGasPrices(t *testing.T) { tests := []struct { name string diff --git a/sei-cosmos/server/config/testdata/base_config.keys.golden b/sei-cosmos/server/config/testdata/base_config.keys.golden new file mode 100644 index 0000000000..b0a7b30124 --- /dev/null +++ b/sei-cosmos/server/config/testdata/base_config.keys.golden @@ -0,0 +1,14 @@ +"minimum-gas-prices" +"inter-block-cache" +"pruning" +"pruning-keep-recent" +"pruning-interval" +"halt-height" +"halt-time" +"index-events" +"min-retain-blocks" +"compaction-interval" +"concurrency-workers" +"occ-enabled" +"freeze-height" +# keys with a target of their own diff --git a/sei-cosmos/server/config/testdata/server_config.golden b/sei-cosmos/server/config/testdata/server_config.golden new file mode 100644 index 0000000000..32fdcd7f3f --- /dev/null +++ b/sei-cosmos/server/config/testdata/server_config.golden @@ -0,0 +1,153 @@ +MinGasPrices = string("0.01usei") +Pruning = string("nothing") +PruningKeepRecent = string("0") +PruningKeepEvery = string("0") +PruningInterval = string("0") +HaltHeight = uint64(0) +FreezeHeight = uint64(0) +HaltTime = uint64(0) +MinRetainBlocks = uint64(0) +InterBlockCache = bool(true) +IndexEvents = +CompactionInterval = uint64(0) +ConcurrencyWorkers = +OccEnabled = bool(true) +Telemetry.ServiceName = string("") +Telemetry.Enabled = bool(true) +Telemetry.EnableHostname = bool(false) +Telemetry.EnableHostnameLabel = bool(false) +Telemetry.EnableServiceLabel = bool(false) +Telemetry.PrometheusRetentionTime = int64(7200) +Telemetry.GlobalLabels = +API.Enable = bool(false) +API.Swagger = bool(true) +API.EnableUnsafeCORS = bool(false) +API.Address = string("tcp://0.0.0.0:1317") +API.MaxOpenConnections = uint(1000) +API.RPCReadTimeout = uint(10) +API.RPCWriteTimeout = uint(0) +API.RPCMaxBodyBytes = uint(1000000) +GRPC.Enable = bool(true) +GRPC.Address = string("0.0.0.0:9090") +GRPC.MaxRecvMsgSize = int(4194304) +GRPC.MaxOpenConnections = uint(1000) +GRPC.MaxConnectionIdle = time.Duration(5m0s) +GRPC.MaxConnectionAge = time.Duration(0s) +GRPC.MaxConnectionAgeGrace = time.Duration(0s) +GRPC.KeepaliveTime = time.Duration(2h0m0s) +GRPC.KeepaliveTimeout = time.Duration(20s) +GRPC.KeepaliveMinTime = time.Duration(5m0s) +GRPC.KeepalivePermitWithoutStream = bool(false) +Rosetta.Address = string(":8080") +Rosetta.Blockchain = string("app") +Rosetta.Network = string("network") +Rosetta.Retries = int(3) +Rosetta.Enable = bool(false) +Rosetta.Offline = bool(false) +GRPCWeb.Enable = bool(true) +GRPCWeb.Address = string("0.0.0.0:9091") +GRPCWeb.EnableUnsafeCORS = bool(false) +GRPCWeb.MaxOpenConnections = uint(1000) +StateSync.SnapshotInterval = uint64(0) +StateSync.SnapshotKeepRecent = uint32(2) +StateSync.SnapshotDirectory = string("") +StateCommit.Enable = bool(true) +StateCommit.Directory = string("") +StateCommit.AsyncCommitBuffer = int(0) +StateCommit.WriteMode = types.WriteMode("memiavl_only") +StateCommit.WriteModeEnableAuto = bool(true) +StateCommit.MemIAVLConfig.AsyncCommitBuffer = int(100) +StateCommit.MemIAVLConfig.SnapshotKeepRecent = uint32(1) +StateCommit.MemIAVLConfig.SnapshotInterval = uint32(10000) +StateCommit.MemIAVLConfig.SnapshotMinTimeInterval = uint32(3600) +StateCommit.MemIAVLConfig.SnapshotWriterLimit = int(4) +StateCommit.MemIAVLConfig.SnapshotPrefetchThreshold = float64(0.8) +StateCommit.MemIAVLConfig.SnapshotWriteRateMBps = int(100) +StateCommit.FlatKVConfig.DataDir = string("") +StateCommit.FlatKVConfig.Fsync = bool(false) +StateCommit.FlatKVConfig.AsyncWriteBuffer = int(0) +StateCommit.FlatKVConfig.SnapshotInterval = uint32(10000) +StateCommit.FlatKVConfig.SnapshotKeepRecent = uint32(1) +StateCommit.FlatKVConfig.ExternalPruning = bool(false) +StateCommit.FlatKVConfig.EnablePebbleMetrics = bool(true) +StateCommit.FlatKVConfig.EnableReadWriteMetrics = bool(false) +StateCommit.FlatKVConfig.AccountDBConfig.DataDir = string("") +StateCommit.FlatKVConfig.AccountDBConfig.EnableMetrics = bool(true) +StateCommit.FlatKVConfig.AccountDBConfig.EnableReadWriteMetrics = bool(false) +StateCommit.FlatKVConfig.AccountDBConfig.MetricsScrapeInterval = time.Duration(10s) +StateCommit.FlatKVConfig.AccountCacheConfig.ShardCount = uint64(8) +StateCommit.FlatKVConfig.AccountCacheConfig.MaxSize = uint64(1073741824) +StateCommit.FlatKVConfig.AccountCacheConfig.EstimatedOverheadPerEntry = uint64(250) +StateCommit.FlatKVConfig.AccountCacheConfig.MetricsName = string("") +StateCommit.FlatKVConfig.AccountCacheConfig.MetricsScrapeInterval = time.Duration(0s) +StateCommit.FlatKVConfig.CodeDBConfig.DataDir = string("") +StateCommit.FlatKVConfig.CodeDBConfig.EnableMetrics = bool(true) +StateCommit.FlatKVConfig.CodeDBConfig.EnableReadWriteMetrics = bool(false) +StateCommit.FlatKVConfig.CodeDBConfig.MetricsScrapeInterval = time.Duration(10s) +StateCommit.FlatKVConfig.CodeCacheConfig.ShardCount = uint64(8) +StateCommit.FlatKVConfig.CodeCacheConfig.MaxSize = uint64(536870912) +StateCommit.FlatKVConfig.CodeCacheConfig.EstimatedOverheadPerEntry = uint64(250) +StateCommit.FlatKVConfig.CodeCacheConfig.MetricsName = string("") +StateCommit.FlatKVConfig.CodeCacheConfig.MetricsScrapeInterval = time.Duration(0s) +StateCommit.FlatKVConfig.StorageDBConfig.DataDir = string("") +StateCommit.FlatKVConfig.StorageDBConfig.EnableMetrics = bool(true) +StateCommit.FlatKVConfig.StorageDBConfig.EnableReadWriteMetrics = bool(false) +StateCommit.FlatKVConfig.StorageDBConfig.MetricsScrapeInterval = time.Duration(10s) +StateCommit.FlatKVConfig.StorageCacheConfig.ShardCount = uint64(8) +StateCommit.FlatKVConfig.StorageCacheConfig.MaxSize = uint64(4294967296) +StateCommit.FlatKVConfig.StorageCacheConfig.EstimatedOverheadPerEntry = uint64(250) +StateCommit.FlatKVConfig.StorageCacheConfig.MetricsName = string("") +StateCommit.FlatKVConfig.StorageCacheConfig.MetricsScrapeInterval = time.Duration(0s) +StateCommit.FlatKVConfig.MiscDBConfig.DataDir = string("") +StateCommit.FlatKVConfig.MiscDBConfig.EnableMetrics = bool(true) +StateCommit.FlatKVConfig.MiscDBConfig.EnableReadWriteMetrics = bool(false) +StateCommit.FlatKVConfig.MiscDBConfig.MetricsScrapeInterval = time.Duration(10s) +StateCommit.FlatKVConfig.MiscCacheConfig.ShardCount = uint64(8) +StateCommit.FlatKVConfig.MiscCacheConfig.MaxSize = uint64(536870912) +StateCommit.FlatKVConfig.MiscCacheConfig.EstimatedOverheadPerEntry = uint64(250) +StateCommit.FlatKVConfig.MiscCacheConfig.MetricsName = string("") +StateCommit.FlatKVConfig.MiscCacheConfig.MetricsScrapeInterval = time.Duration(0s) +StateCommit.FlatKVConfig.MetadataDBConfig.DataDir = string("") +StateCommit.FlatKVConfig.MetadataDBConfig.EnableMetrics = bool(true) +StateCommit.FlatKVConfig.MetadataDBConfig.EnableReadWriteMetrics = bool(false) +StateCommit.FlatKVConfig.MetadataDBConfig.MetricsScrapeInterval = time.Duration(10s) +StateCommit.FlatKVConfig.MetadataCacheConfig.ShardCount = uint64(8) +StateCommit.FlatKVConfig.MetadataCacheConfig.MaxSize = uint64(536870912) +StateCommit.FlatKVConfig.MetadataCacheConfig.EstimatedOverheadPerEntry = uint64(250) +StateCommit.FlatKVConfig.MetadataCacheConfig.MetricsName = string("") +StateCommit.FlatKVConfig.MetadataCacheConfig.MetricsScrapeInterval = time.Duration(0s) +StateCommit.FlatKVConfig.ReaderThreadsPerCore = float64(2) +StateCommit.FlatKVConfig.ReaderConstantThreadCount = int(0) +StateCommit.FlatKVConfig.ReaderPoolQueueSize = int(1024) +StateCommit.FlatKVConfig.MiscPoolThreadsPerCore = float64(4) +StateCommit.FlatKVConfig.MiscConstantThreadCount = int(0) +StateCommit.FlatKVConfig.LtHashThreadsPerCore = float64(1) +StateCommit.HistoricalProofMaxInFlight = int(1) +StateCommit.HistoricalProofRateLimit = float64(1) +StateCommit.HistoricalProofBurst = int(1) +StateCommit.HashLogger.Enable = bool(true) +StateCommit.HashLogger.Directory = string("") +StateCommit.HashLogger.BlocksToRetain = uint(0) +StateCommit.HashLogger.TargetFileSize = uint(16777216) +StateCommit.HashLogger.MaxDiskSize = uint(17179869184) +StateCommit.HashLogger.Version = string("") +StateStore.Enable = bool(true) +StateStore.DBDirectory = string("") +StateStore.Backend = string("pebbledb") +StateStore.AsyncWriteBuffer = int(100) +StateStore.KeepRecent = int(100000) +StateStore.PruneIntervalSeconds = int(600) +StateStore.ImportNumWorkers = int(1) +StateStore.EnableReadWriteMetrics = bool(false) +StateStore.KeepLastVersion = bool(true) +StateStore.UseDefaultComparer = bool(false) +StateStore.SnapshotEnable = bool(false) +StateStore.SnapshotInterval = int64(0) +StateStore.SnapshotKeepRecent = int(0) +StateStore.SnapshotMinTimeInterval = time.Duration(0s) +StateStore.ExternalPruning = bool(false) +StateStore.EVMSplit = bool(false) +StateStore.EVMDBDirectory = string("") +StateStore.SeparateEVMSubDBs = bool(false) +Genesis.StreamImport = bool(false) +Genesis.GenesisStreamFile = string("") diff --git a/sei-cosmos/server/config/toml.go b/sei-cosmos/server/config/toml.go index ac931084be..5283bea538 100644 --- a/sei-cosmos/server/config/toml.go +++ b/sei-cosmos/server/config/toml.go @@ -41,6 +41,10 @@ occ-enabled = {{ .BaseConfig.OccEnabled }} # Note: Commitment of state will be attempted on the corresponding block. halt-height = {{ .BaseConfig.HaltHeight }} +# FreezeHeight contains a non-zero block height at which the node stops before +# executing the block while continuing to serve RPC. +freeze-height = {{ .BaseConfig.FreezeHeight }} + # HaltTime contains a non-zero minimum block time (in Unix seconds) at which # a node will gracefully halt and shutdown that can be used to assist upgrades # and testing. diff --git a/sei-cosmos/server/start.go b/sei-cosmos/server/start.go index 514c922cc7..93dd34e6e3 100644 --- a/sei-cosmos/server/start.go +++ b/sei-cosmos/server/start.go @@ -44,6 +44,7 @@ const ( flagCPUProfile = "cpu-profile" FlagMinGasPrices = "minimum-gas-prices" FlagHaltHeight = "halt-height" + FlagFreezeHeight = "freeze-height" FlagHaltTime = "halt-time" FlagInterBlockCache = "inter-block-cache" FlagUnsafeSkipUpgrades = "unsafe-skip-upgrades" @@ -102,6 +103,8 @@ the ABCI Commit phase, the node will check if the current block height is greate the halt-height or if the current block time is greater than or equal to the halt-time. If so, the node will attempt to gracefully shutdown and the block will not be committed. In addition, the node will not be able to commit subsequent blocks. +The '--freeze-height' flag instead keeps the process and RPC servers running while preventing block +sync and consensus from executing the block at the configured height or advancing beyond it. For profiling and benchmarking purposes, CPU profiling can be enabled via the '--cpu-profile' flag which accepts a path for the resulting pprof file. The node may be started in a 'query only' mode where only the gRPC and JSON HTTP @@ -211,6 +214,7 @@ func addStartNodeFlags(cmd *cobra.Command, defaultNodeHome string) { cmd.Flags().String(FlagMinGasPrices, "", "Minimum gas prices to accept for transactions; Any fee in a tx must meet this minimum (e.g. 0.01photino;0.0001stake)") cmd.Flags().IntSlice(FlagUnsafeSkipUpgrades, []int{}, "Skip a set of upgrade heights to continue the old binary") cmd.Flags().Uint64(FlagHaltHeight, 0, "Block height at which to gracefully halt the chain and shutdown the node") + cmd.Flags().Uint64(FlagFreezeHeight, 0, "Block height to stop before executing while continuing to serve RPC") cmd.Flags().Uint64(FlagHaltTime, 0, "Minimum block time (in Unix seconds) at which to gracefully halt the chain and shutdown the node") cmd.Flags().Bool(FlagInterBlockCache, true, "Enable inter-block caching") cmd.Flags().String(flagCPUProfile, "", "Enable CPU profiling and write to the provided file") @@ -295,6 +299,13 @@ func startInProcess( if err != nil { return err } + if err := config.ValidateFreeze(); err != nil { + return err + } + gRPCOnly := ctx.Viper.GetBool(flagGRPCOnly) + if gRPCOnly && config.FreezeHeight > 0 { + return errors.New("freeze-height cannot be used with grpc-only mode") + } if err := config.ValidateBasic(ctx.Config); err != nil { logger.Error("WARNING: The minimum-gas-prices config in app.toml is set to the empty string. " + @@ -309,8 +320,6 @@ func startInProcess( } }() - gRPCOnly := ctx.Viper.GetBool(flagGRPCOnly) - var restartMtx sync.Mutex restartCh := make(chan struct{}) restartEvent := func() { @@ -351,6 +360,7 @@ func startInProcess( tracerProviderOptions, nodeMetricsProvider, tmtypes.DefaultConsensusPolicy(), + node.WithFreezeHeight(config.FreezeHeight), ) if err != nil { return fmt.Errorf("error creating node: %w", err) diff --git a/sei-tendermint/internal/blocksync/reactor.go b/sei-tendermint/internal/blocksync/reactor.go index 24db695a30..ebd4bbdd23 100644 --- a/sei-tendermint/internal/blocksync/reactor.go +++ b/sei-tendermint/internal/blocksync/reactor.go @@ -109,6 +109,7 @@ type SyncerConfig struct { EventBus *eventbus.EventBus RestartEvent func() SelfRemediationConfig *config.SelfRemediationConfig + FreezeHeight uint64 } // Reactor owns the blocksync channel and always-on query serving path, while @@ -156,6 +157,7 @@ type syncController struct { blocksBehindThreshold uint64 blocksBehindCheckInterval time.Duration restartCooldownSeconds uint64 + freezeHeight uint64 // blocksyncReady fires when the active sync routines should begin processing // work, either during OnStart or later via SwitchToBlockSync. @@ -189,6 +191,7 @@ func NewReactor( blocksBehindThreshold: cfg.SelfRemediationConfig.BlocksBehindThreshold, blocksBehindCheckInterval: time.Duration(cfg.SelfRemediationConfig.BlocksBehindCheckIntervalSeconds) * time.Second, //nolint:gosec // validated in config.ValidateBasic against MaxInt64 restartCooldownSeconds: cfg.SelfRemediationConfig.RestartCooldownSeconds, + freezeHeight: cfg.FreezeHeight, blocksyncReady: utils.NewAtomicSend(utils.None[blocksyncResult]()), startInBlockSync: cfg.BlockSync, } @@ -378,6 +381,9 @@ func (s *syncController) run(ctx context.Context) error { if r, ok := s.consReactor.Get(); ok { logger.Info("switching to consensus reactor", "height", handoff.height, "blocks_synced", handoff.blocksSynced, "state_synced", handoff.stateSynced, "max_peer_height", handoff.maxPeerHeight) r.SwitchToConsensus(handoff.state, handoff.blocksSynced > 0 || handoff.stateSynced) + if s.shouldFreeze(handoff.state) { + return nil + } s.autoRestartIfBehind(ctx, pool) } return nil @@ -465,6 +471,10 @@ func (s *syncController) requestRoutine(ctx context.Context, pool *BlockPool) er // // NOTE: Don't sleep in the FOR_LOOP or otherwise slow it down! func (s *syncController) poolRoutine(ctx context.Context, pool *BlockPool, initialState sm.State, stateSynced bool) (consensusHandoff, error) { + if handoff, frozen := s.frozenHandoff(pool, initialState, 0, stateSynced); frozen { + return handoff, nil + } + var ( trySyncTicker = time.NewTicker(trySyncIntervalMS * time.Millisecond) switchToConsensusTicker = time.NewTicker(switchToConsensusIntervalSeconds * time.Second) @@ -581,6 +591,9 @@ func (s *syncController) poolRoutine(ctx context.Context, pool *BlockPool, initi s.metrics.RecordConsMetrics(first) blocksSynced++ + if handoff, frozen := s.frozenHandoff(pool, state, blocksSynced, stateSynced); frozen { + return handoff, nil + } if blocksSynced%100 == 0 { lastRate = 0.9*lastRate + 0.1*(100/time.Since(lastHundred).Seconds()) @@ -596,6 +609,26 @@ func (s *syncController) poolRoutine(ctx context.Context, pool *BlockPool, initi } } +func (s *syncController) frozenHandoff(pool *BlockPool, state sm.State, blocksSynced uint64, stateSynced bool) (consensusHandoff, bool) { + if !s.shouldFreeze(state) { + return consensusHandoff{}, false + } + height, _, _ := pool.GetStatus() + logger.Info("Block sync stopped before configured freeze height", "last_block_height", state.LastBlockHeight, "freeze_height", s.freezeHeight) + return consensusHandoff{ + state: state, + blocksSynced: blocksSynced, + stateSynced: stateSynced, + height: height, + maxPeerHeight: pool.MaxPeerHeight(), + }, true +} + +func (s *syncController) shouldFreeze(state sm.State) bool { + height := startHeightForState(state) + return s.freezeHeight > 0 && height >= 0 && uint64(height) >= s.freezeHeight //nolint:gosec // negative heights are rejected first. +} + // autoRestartIfBehind will check if the node is behind the max peer height by // a certain threshold. If it is, the node will attempt to restart itself. // TODO(gprusak): this should be a sub task of the consensus reactor instead. @@ -612,6 +645,10 @@ func (s *syncController) autoRestartIfBehind(ctx context.Context, pool *BlockPoo select { case <-time.After(s.blocksBehindCheckInterval): selfHeight := s.store.Height() + if s.freezeHeight > 0 && selfHeight >= 0 && uint64(selfHeight) >= s.freezeHeight-1 { //nolint:gosec // negative heights are rejected first. + logger.Info("Auto remediation stopped at configured freeze height", "selfHeight", selfHeight, "freeze_height", s.freezeHeight) + return + } maxPeerHeight := pool.MaxPeerHeight() threshold := int64(s.blocksBehindThreshold) //nolint:gosec // validated in config.ValidateBasic against MaxInt64 behindHeight := maxPeerHeight - selfHeight diff --git a/sei-tendermint/internal/blocksync/reactor_test.go b/sei-tendermint/internal/blocksync/reactor_test.go index 075c06f46a..870d7ea668 100644 --- a/sei-tendermint/internal/blocksync/reactor_test.go +++ b/sei-tendermint/internal/blocksync/reactor_test.go @@ -6,6 +6,7 @@ import ( "runtime" "strings" "testing" + "testing/synctest" "time" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/mempool" @@ -27,6 +28,7 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/internal/store" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/test/factory" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" + utilsrequire "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" pb "github.com/sei-protocol/sei-chain/sei-tendermint/proto/tendermint/blocksync" "github.com/sei-protocol/sei-chain/sei-tendermint/types" ) @@ -460,6 +462,7 @@ func TestAutoRestartIfBehind(t *testing.T) { } } +<<<<<<< HEAD func makeValidationFailurePair( ctx context.Context, t *testing.T, @@ -616,6 +619,30 @@ func TestPoolRoutine_RetriesAfterValidationFailure(t *testing.T) { } } } +======= +func TestAutoRestartStopsAtFreezeBoundary(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + const freezeHeight = uint64(101) + mockBlockStore := new(MockBlockStore) + mockBlockStore.On("Height").Return(int64(freezeHeight - 1)) + + blockPool := &BlockPool{ + height: int64(freezeHeight), + maxPeerHeight: int64(freezeHeight + 100), + } + restart := utils.NewAtomicSend(false) + syncer := &syncController{ + store: mockBlockStore, + blocksBehindThreshold: 1, + blocksBehindCheckInterval: time.Hour, + freezeHeight: freezeHeight, + restartEvent: func() { restart.Store(true) }, + } + + syncer.autoRestartIfBehind(t.Context(), blockPool) + utilsrequire.False(t, restart.Load()) + }) +>>>>>>> 20eb288 (Add freeze mode for historical EVM RPC (#3910)) } func TestQueryResponder_ServesBlockRequestsWhenBlockSyncDisabled(t *testing.T) { @@ -667,6 +694,27 @@ func TestQueryResponder_ServesBlockRequestsWhenBlockSyncDisabled(t *testing.T) { t.Fatal("did not receive block response") } +func TestPoolRoutineHandsOffAtFreezeHeight(t *testing.T) { + const freezeHeight = int64(10) + pool := NewBlockPool(freezeHeight, nil) + syncer := &syncController{freezeHeight: uint64(freezeHeight)} //nolint:gosec // the test height is positive. + state := sm.State{InitialHeight: 1, LastBlockHeight: freezeHeight - 1} + + handoff, err := syncer.poolRoutine(t.Context(), pool, state, false) + if err != nil { + t.Fatalf("poolRoutine: %v", err) + } + if handoff.state.LastBlockHeight != freezeHeight-1 { + t.Fatalf("handoff state height = %d, want %d", handoff.state.LastBlockHeight, freezeHeight-1) + } + if handoff.height != freezeHeight { + t.Fatalf("handoff pool height = %d, want %d", handoff.height, freezeHeight) + } + if handoff.blocksSynced != 0 { + t.Fatalf("handoff blocks synced = %d, want 0", handoff.blocksSynced) + } +} + func TestQueryResponder_ServesStatusRequestsWhenBlockSyncDisabled(t *testing.T) { ctx := t.Context() diff --git a/sei-tendermint/internal/consensus/state.go b/sei-tendermint/internal/consensus/state.go index 37ded169d8..991e8f7177 100644 --- a/sei-tendermint/internal/consensus/state.go +++ b/sei-tendermint/internal/consensus/state.go @@ -11,6 +11,7 @@ import ( "sort" "strconv" "sync" + "sync/atomic" "time" "github.com/gogo/protobuf/proto" @@ -157,6 +158,9 @@ type State struct { heightSpan otrace.Span heightBeingTraced int64 tracingCtx context.Context + + freezeHeight uint64 + frozen atomic.Bool } // NewState returns a new State. @@ -283,6 +287,30 @@ func (cs *State) SetPrivValidator(ctx context.Context, priv utils.Option[types.P } } +// SetFreezeHeight configures the first block height consensus must not execute. +// It must be called before the state starts. +func (cs *State) SetFreezeHeight(height uint64) { + cs.freezeHeight = height + cs.markFrozen(nextHeightForState(cs.state), cs.state.LastBlockHeight) +} + +func (cs *State) markFrozen(nextHeight, lastBlockHeight int64) { + if cs.freezeHeight == 0 || nextHeight < 0 || uint64(nextHeight) < cs.freezeHeight { //nolint:gosec // negative heights are rejected first. + return + } + if cs.frozen.CompareAndSwap(false, true) { + logger.Info("Consensus frozen before configured height", "freeze_height", cs.freezeHeight, "last_block_height", lastBlockHeight) + } +} + +func nextHeightForState(state sm.State) int64 { + height := state.LastBlockHeight + 1 + if height == 1 { + height = state.InitialHeight + } + return height +} + // SetTimeoutTicker sets the local timer. It may be useful to overwrite for // testing. func (cs *State) SetTimeoutTicker(timeoutTicker TimeoutTicker) { @@ -323,14 +351,16 @@ func (cs *State) Run(ctx context.Context) error { // We may have lost some votes if the process crashed reload from consensus // log to catchup. - if cs.doWALCatchup { + if cs.doWALCatchup && !cs.frozen.Load() { if err := cs.catchupReplay(ctx, cs.roundState.Height()); err != nil { return fmt.Errorf("cs.catchupReplay(): %w", err) } } // Double Signing Risk Reduction - if err := cs.checkDoubleSigningRisk(cs.roundState.Height()); err != nil { - return err + if !cs.frozen.Load() { + if err := cs.checkDoubleSigningRisk(cs.roundState.Height()); err != nil { + return err + } } // now start the receiveRoutine @@ -339,7 +369,9 @@ func (cs *State) Run(ctx context.Context) error { // schedule the first round! // use GetRoundState so we don't race the receiveRoutine for access - cs.scheduleRound0(cs.GetRoundState()) + if !cs.frozen.Load() { + cs.scheduleRound0(cs.GetRoundState()) + } return nil }) } @@ -520,6 +552,9 @@ func (cs *State) votesFromSeenCommit(state sm.State) (*types.VoteSet, error) { // Updates State and increments height to match that of state. // The round becomes 0 and cs.Step becomes cstypes.RoundStepNewHeight. func (cs *State) updateToState(state sm.State) { + height := nextHeightForState(state) + cs.markFrozen(height, state.LastBlockHeight) + if cs.roundState.CommitRound() > -1 && 0 < cs.roundState.Height() && cs.roundState.Height() != state.LastBlockHeight { panic(fmt.Sprintf( "updateToState() expected state height of %v but found %v", @@ -584,12 +619,6 @@ func (cs *State) updateToState(state sm.State) { )) } - // Next desired block height - height := state.LastBlockHeight + 1 - if height == 1 { - height = state.InitialHeight - } - // RoundState fields cs.updateHeight(height) cs.updateRoundStep(0, cstypes.RoundStepNewHeight) @@ -633,8 +662,10 @@ func (cs *State) updateToState(state sm.State) { func (cs *State) newStep() { rs := cs.roundState.RoundStateEvent() - if err := cs.wal.Append(NewWALMessage(rs)); err != nil { - panic(fmt.Errorf("failed writing to WAL: %w", err)) + if !cs.frozen.Load() { + if err := cs.wal.Append(NewWALMessage(rs)); err != nil { + panic(fmt.Errorf("failed writing to WAL: %w", err)) + } } cs.nSteps++ @@ -700,6 +731,9 @@ func (cs *State) receiveRoutine(ctx context.Context, maxSteps int) error { } for { + if cs.frozen.Load() { + return cs.receiveWhileFrozen(ctx, txsAvailable) + } if maxSteps > 0 { if cs.nSteps >= maxSteps { logger.Debug("reached max steps; exiting receive routine") @@ -746,6 +780,20 @@ func (cs *State) receiveRoutine(ctx context.Context, maxSteps int) error { // TODO should we handle context cancels here? } } + +func (cs *State) receiveWhileFrozen(ctx context.Context, txsAvailable <-chan struct{}) error { + for { + select { + case <-txsAvailable: + case <-cs.peerMsgQueue: + case <-cs.internalMsgQueue: + case <-cs.timeoutTicker.Chan(): + case <-ctx.Done(): + return ctx.Err() + } + } +} + func (cs *State) fsyncAndCompleteProposal(ctx context.Context, fsyncUponCompletion bool, height int64, span otrace.Span, onPropose bool) { cs.metrics.ProposalBlockCreatedOnPropose.With("success", strconv.FormatBool(onPropose)).Add(1) if fsyncUponCompletion { @@ -974,6 +1022,9 @@ func (cs *State) getTracingCtx(defaultCtx context.Context) context.Context { // Enter: +2/3 prevotes any or +2/3 precommits for block or any from (height, round) // NOTE: cs.StartTime was already set for height. func (cs *State) enterNewRound(ctx context.Context, height int64, round int32, entryLabel string) { + if cs.frozen.Load() { + return + } if height > cs.heightBeingTraced { if cs.heightSpan != nil { cs.heightSpan.End() diff --git a/sei-tendermint/internal/consensus/state_test.go b/sei-tendermint/internal/consensus/state_test.go index 1eedfd7ecf..b0e03536ce 100644 --- a/sei-tendermint/internal/consensus/state_test.go +++ b/sei-tendermint/internal/consensus/state_test.go @@ -316,6 +316,49 @@ func TestStateFullRound1(t *testing.T) { cs.validateLastPrecommit(ctx, t, vss[0], propBlock.Hash) } +func TestStateFreezesAfterTargetBlock(t *testing.T) { + config := configSetup(t) + ctx := t.Context() + + cs, _ := makeState(ctx, t, makeStateArgs{config: config, validators: 1}) + height, round := cs.roundState.Height(), cs.roundState.Round() + cs.SetFreezeHeight(uint64(height + 1)) //nolint:gosec // consensus heights are non-negative. + require.False(t, cs.frozen.Load()) + + voteCh := subscribe(ctx, t, cs.eventBus, types.EventQueryVote) + proposalCh := subscribe(ctx, t, cs.eventBus, types.EventQueryCompleteProposal) + newRoundCh := subscribe(ctx, t, cs.eventBus, types.EventQueryNewRound) + frozenHeightCh := make(chan *cstypes.RoundState, 1) + cs.eventNewRoundStep = func(rs *cstypes.RoundState) { + if rs.Height == height+1 { + frozenHeightCh <- rs + } + } + + cs.startTestRound(ctx, height, round) + ensureNewRound(t, newRoundCh, height, round) + proposal := ensureNewProposal(t, proposalCh, height, round) + ensurePrevoteMatch(t, voteCh, height, round, proposal.Hash) + ensurePrecommit(t, voteCh, height, round) + + frozenState := <-frozenHeightCh + require.True(t, cs.frozen.Load()) + require.Equal(t, height+1, frozenState.Height) + require.Equal(t, cstypes.RoundStepNewHeight, frozenState.Step) + walHeight, walMessages, err := cs.wal.ReadLastHeightMsgs() + require.NoError(t, err) + require.Equal(t, height+1, walHeight) + require.Empty(t, walMessages) + + cs.enterNewRound(ctx, height+1, 0, "test") + require.Equal(t, cstypes.RoundStepNewHeight, cs.GetRoundState().Step) + select { + case event := <-newRoundCh: + t.Fatalf("consensus entered a round above the freeze height: %v", event) + default: + } +} + // nil is proposed, so prevote and precommit nil func TestStateFullRoundNil(t *testing.T) { config := configSetup(t) diff --git a/sei-tendermint/node/freeze_test.go b/sei-tendermint/node/freeze_test.go new file mode 100644 index 0000000000..12125881cf --- /dev/null +++ b/sei-tendermint/node/freeze_test.go @@ -0,0 +1,41 @@ +package node + +import ( + "math" + "testing" +) + +func TestValidateFreezeHeight(t *testing.T) { + for _, tc := range []struct { + name string + freezeHeight uint64 + initialHeight int64 + stateHeight int64 + blockHeight int64 + appHeight int64 + wantErr bool + }{ + {name: "disabled"}, + {name: "below target", freezeHeight: 10, initialHeight: 1, stateHeight: 8, blockHeight: 9, appHeight: 8}, + {name: "immediately before target", freezeHeight: 10, initialHeight: 1, stateHeight: 9, blockHeight: 9, appHeight: 9}, + {name: "target below initial height", freezeHeight: 9, initialHeight: 10, wantErr: true}, + {name: "state at target", freezeHeight: 10, initialHeight: 1, stateHeight: 10, wantErr: true}, + {name: "block store at target", freezeHeight: 10, initialHeight: 1, blockHeight: 10, wantErr: true}, + {name: "application at target", freezeHeight: 10, initialHeight: 1, appHeight: 10, wantErr: true}, + {name: "target above max height", freezeHeight: uint64(math.MaxInt64) + 1, initialHeight: 1, wantErr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + err := validateFreezeHeight(tc.freezeHeight, tc.initialHeight, tc.stateHeight, tc.blockHeight, tc.appHeight) + if (err != nil) != tc.wantErr { + t.Fatalf("validateFreezeHeight() error = %v, wantErr %t", err, tc.wantErr) + } + }) + } +} + +func TestWithFreezeHeight(t *testing.T) { + const height = uint64(123) + if got := resolveOptions(WithFreezeHeight(height)).freezeHeight; got != height { + t.Fatalf("freeze height = %d, want %d", got, height) + } +} diff --git a/sei-tendermint/node/node.go b/sei-tendermint/node/node.go index c834cf8f11..109d97d8a0 100644 --- a/sei-tendermint/node/node.go +++ b/sei-tendermint/node/node.go @@ -3,6 +3,7 @@ package node import ( "context" "fmt" + "math" "net" "net/http" _ "net/http/pprof" // nolint: gosec // securely exposed on separate, optional port @@ -44,6 +45,69 @@ import ( _ "github.com/lib/pq" // provide the psql db driver ) +<<<<<<< HEAD +======= +type chainIDGatherer struct{ chainID string } + +func (g chainIDGatherer) Gather() ([]*dto.MetricFamily, error) { + metricFamilies, err := prometheus.DefaultGatherer.Gather() + if err != nil { + return nil, err + } + for _, metricFamily := range metricFamilies { + for _, metric := range metricFamily.Metric { + if hasMetricLabel(metric, "chain_id") { + continue + } + labels := slices.Clone(metric.Label) + labels = append(labels, &dto.LabelPair{ + Name: proto.String("chain_id"), + Value: proto.String(g.chainID), + }) + slices.SortFunc(labels, func(a, b *dto.LabelPair) int { + return strings.Compare(a.GetName(), b.GetName()) + }) + metric.Label = labels + } + } + return metricFamilies, nil +} + +func hasMetricLabel(metric *dto.Metric, name string) bool { + for _, label := range metric.GetLabel() { + if label.GetName() == name { + return true + } + } + return false +} + +func validateFreezeHeight(freezeHeight uint64, initialHeight, stateHeight, blockStoreHeight, appHeight int64) error { + if freezeHeight == 0 { + return nil + } + if freezeHeight > math.MaxInt64 { + return fmt.Errorf("freeze height %d exceeds the maximum block height", freezeHeight) + } + if initialHeight > int64(freezeHeight) { //nolint:gosec // freezeHeight is bounded above. + return fmt.Errorf("freeze height %d is below initial height %d", freezeHeight, initialHeight) + } + for _, current := range []struct { + source string + height int64 + }{ + {source: "application", height: appHeight}, + {source: "block store", height: blockStoreHeight}, + {source: "state store", height: stateHeight}, + } { + if current.height >= int64(freezeHeight) { //nolint:gosec // freezeHeight is bounded above. + return fmt.Errorf("%s height %d has already reached freeze height %d", current.source, current.height, freezeHeight) + } + } + return nil +} + +>>>>>>> 20eb288 (Add freeze mode for historical EVM RPC (#3910)) // nodeImpl is the highest level interface to a full Tendermint node. // It includes all configuration information and running services. type nodeImpl struct { @@ -55,6 +119,7 @@ type nodeImpl struct { privValidator types.PrivValidator // local node's validator key shouldHandshake bool // set during makeNode consensusPolicy types.ConsensusPolicy + freezeHeight uint64 // network router *p2p.Router @@ -90,8 +155,17 @@ func makeNode( tracerProviderOptions []trace.TracerProviderOption, nodeMetrics *NodeMetrics, consensusPolicy types.ConsensusPolicy, + nodeOptions ...Option, ) (_ local.NodeService, err error) { +<<<<<<< HEAD var cancel context.CancelFunc +======= + opts := resolveOptions(nodeOptions...) + var ( + cancel context.CancelFunc + node *nodeImpl + ) +>>>>>>> 20eb288 (Add freeze mode for historical EVM RPC (#3910)) ctx, cancel = context.WithCancel(ctx) closers := []closer{convertCancelCloser(cancel)} defer func() { @@ -120,6 +194,12 @@ func makeNode( if err != nil { return nil, fmt.Errorf("LoadStateFromDBOrGenesisDocProvider(): %w", err) } + if err := validateFreezeHeight(opts.freezeHeight, genDoc.InitialHeight, state.LastBlockHeight, blockStore.Height(), proxyApp.Info().LastBlockHeight); err != nil { + return nil, err + } + if opts.freezeHeight > 0 && cfg.AutobahnConfigFile != "" { + return nil, errors.New("freeze height is not supported with Autobahn") + } eventBus := eventbus.NewDefault() @@ -168,6 +248,7 @@ func makeNode( genesisDoc: genDoc, privValidator: privValidator, consensusPolicy: consensusPolicy, + freezeHeight: opts.freezeHeight, nodeKey: nodeKey, @@ -260,6 +341,10 @@ func makeNode( // Determine whether we should attempt state sync. stateSync := cfg.StateSync.Enable && !onlyValidatorIsUs(state, pubKey) + if stateSync && opts.freezeHeight > 0 { + logger.Info("Freeze mode disables state sync; falling back to block sync", "freeze_height", opts.freezeHeight) + stateSync = false + } if stateSync && state.LastBlockHeight > 0 { logger.Info("Found local state with non-zero height, skipping state sync") stateSync = false @@ -289,6 +374,7 @@ func makeNode( tracerProviderOptions, nodeMetrics.consensus, ) + csState.SetFreezeHeight(opts.freezeHeight) node.rpcEnv.ConsensusState = utils.Some[rpccore.ConsensusState](csState) csReactor, err := consensus.NewReactor( @@ -320,6 +406,7 @@ func makeNode( EventBus: eventBus, RestartEvent: restartEvent, SelfRemediationConfig: cfg.SelfRemediation, + FreezeHeight: opts.freezeHeight, }), ) if err != nil { @@ -414,7 +501,26 @@ func makeNode( } // OnStart starts the Node. It implements service.Service. +<<<<<<< HEAD func (n *nodeImpl) OnStart(ctx context.Context) error { +======= +func (n *nodeImpl) OnStart(ctx context.Context) (err error) { + // If Start fails before giga is spawned, BaseService does not call OnStop + // and never cancels SpawnCritical — so BlockDB would otherwise leak. + // When giga has already been spawned, its wrapper closes BlockDB after + // Run observes the service-context cancel issued once OnStart returns. + gigaSpawned := false + if n.freezeHeight > 0 { + logger.Info("Freeze mode enabled", "freeze_height", n.freezeHeight) + } + defer func() { + if err == nil || gigaSpawned { + return + } + _ = n.closeGigaBlockDB() + }() + +>>>>>>> 20eb288 (Add freeze mode for historical EVM RPC (#3910)) // EventBus and IndexerService must be started before the handshake because // we might need to index the txs of the replayed block as this might not have happened // when the node stopped last time (i.e. the node stopped or crashed after it saved the block diff --git a/sei-tendermint/node/public.go b/sei-tendermint/node/public.go index 628e5e628b..9e69cfe416 100644 --- a/sei-tendermint/node/public.go +++ b/sei-tendermint/node/public.go @@ -17,11 +17,30 @@ import ( var logger = seilog.NewLogger("tendermint", "node") -// New constructs a tendermint node. The provided app runs in the same -// process as the tendermint node and will be wrapped in a local ABCI client -// inside this function. The final option is a pointer to a Genesis document: -// if the value is nil, the genesis document is read from the file specified -// in the config, and otherwise the node uses value of the final argument. +type options struct { + freezeHeight uint64 +} + +// Option configures optional node behavior. +type Option func(*options) + +// WithFreezeHeight stops block sync and consensus before executing height; 0 disables freezing. +func WithFreezeHeight(height uint64) Option { + return func(opts *options) { + opts.freezeHeight = height + } +} + +func resolveOptions(nodeOptions ...Option) options { + var opts options + for _, apply := range nodeOptions { + apply(&opts) + } + return opts +} + +// New constructs a Tendermint node around an in-process ABCI application. +// A non-nil genesis document overrides the file selected by the node config. func New( ctx context.Context, conf *config.Config, @@ -31,6 +50,7 @@ func New( tracerProviderOptions []trace.TracerProviderOption, nodeMetrics *NodeMetrics, consensusPolicy tmtypes.ConsensusPolicy, + nodeOptions ...Option, ) (local.NodeService, error) { proxyApp := proxy.New(app, nodeMetrics.proxy) nodeKey, err := tmtypes.LoadOrGenNodeKey(conf.NodeKeyFile()) @@ -65,8 +85,12 @@ func New( tracerProviderOptions, nodeMetrics, consensusPolicy, + nodeOptions..., ) case config.ModeSeed: + if resolveOptions(nodeOptions...).freezeHeight > 0 { + return nil, fmt.Errorf("freeze height is not supported in seed mode") + } return makeSeedNode( conf, config.DefaultDBProvider, From b1f09f7ffd2cfd621f107f5679ff7b2709ce7832 Mon Sep 17 00:00:00 2001 From: "Masih H. Derkani" Date: Fri, 21 Aug 2026 14:47:46 +0100 Subject: [PATCH 2/2] Resolve conflicts --- cmd/seid/cmd/legacy_config_fuzz_test.go | 1261 -------------- sei-cosmos/server/config/config_fuzz_test.go | 1528 ----------------- sei-cosmos/server/config/config_test.go | 37 +- sei-cosmos/server/config/freeze_test.go | 39 + .../config/testdata/base_config.keys.golden | 14 - .../config/testdata/server_config.golden | 153 -- .../internal/blocksync/reactor_test.go | 5 +- sei-tendermint/node/node.go | 81 +- 8 files changed, 57 insertions(+), 3061 deletions(-) delete mode 100644 cmd/seid/cmd/legacy_config_fuzz_test.go delete mode 100644 sei-cosmos/server/config/config_fuzz_test.go create mode 100644 sei-cosmos/server/config/freeze_test.go delete mode 100644 sei-cosmos/server/config/testdata/base_config.keys.golden delete mode 100644 sei-cosmos/server/config/testdata/server_config.golden diff --git a/cmd/seid/cmd/legacy_config_fuzz_test.go b/cmd/seid/cmd/legacy_config_fuzz_test.go deleted file mode 100644 index 9c8ad68efb..0000000000 --- a/cmd/seid/cmd/legacy_config_fuzz_test.go +++ /dev/null @@ -1,1261 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "io" - "maps" - "os" - "reflect" - "slices" - "strings" - "testing" - - "go.opentelemetry.io/otel/sdk/trace" - - "github.com/sei-protocol/sei-chain/cmd/seid/cmd/configmanager" - "github.com/sei-protocol/sei-chain/sei-cosmos/client/flags" - "github.com/sei-protocol/sei-chain/sei-cosmos/server" - seidbconfig "github.com/sei-protocol/sei-chain/sei-db/config" - "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/memiavl" - tmcfg "github.com/sei-protocol/sei-chain/sei-tendermint/config" - wasmtypes "github.com/sei-protocol/sei-chain/sei-wasmd/x/wasm/types" - "github.com/sei-protocol/sei-chain/testutil/configtest" - "github.com/spf13/cast" - "github.com/spf13/cobra" -) - -// This file pins the legacy boot seam itself: LegacyConfigManager.Apply, which -// forwards verbatim to server.InterceptConfigsPreRunHandler. -// -// Apply is the whole legacy configuration path in one call. It resolves --home, -// creates or reads config/config.toml, unmarshals it into a tmcfg.Config, creates -// or merges config/app.toml into the same viper, binds every cobra flag, and -// leaves two channels behind for the rest of the boot: -// -// serverCtx.Config — the Tendermint config struct, populated by viper.Unmarshal -// serverCtx.Viper — the flat key/value map every appOpts.Get() call site reads -// -// Those two channels are the entire interface between configuration and a running -// node, which is what makes them the right thing to pin. A replacement manager is -// correct exactly insofar as it leaves the same two channels in the same state, -// and every target here states one property of that state precisely enough for a -// second implementation to be measured against it. -// -// Everything runs in a pinned environment (configtest.Isolate) against a fixture -// home. That is not tidiness: the path reads bare environment variables and $HOME, -// so an un-pinned environment makes the assertions mean different things on -// different machines. - -// applyResult is what one boot through the legacy manager leaves behind. -type applyResult struct { - ctx *server.Context - err error -} - -// applyLegacy boots one fixture home through LegacyConfigManager.Apply with the -// given explicit flags, and returns the resulting channels. -// -// The command is built fresh for every call, from the real server.StartCmd flag -// set and the real initAppConfig template, so the flag universe and the app.toml -// template are the node's own rather than a test's approximation. Setting a flag -// through cmd.Flags().Set marks it Changed, which is exactly how cobra represents -// "the operator passed this on the command line" — so the flag layer here is the -// real flag layer, not a viper override standing in for one. -// -// StartCmd's own PreRunE is deliberately not run. It layers more behavior on top -// (re-binding flags, fail-fast pruning validation, pinning chain-id from -// client.toml at override precedence) which belongs to separate manifest rows; -// this harness isolates Apply. -func applyLegacy(t *testing.T, home *configtest.Home, flagValues map[string]string) applyResult { - t.Helper() - cmd, serverCtx := newApplyCommand(t, home) - setFlags(t, cmd, flagValues) - return applyResult{ctx: serverCtx, err: applyThrough(cmd)} -} - -// newApplyCommand builds the command and server context Apply runs against, with -// --home already pointed at the fixture. It is separate from applyLegacy so a test -// that needs to inspect the flag set *after* Apply — the write-back in bindFlags -// mutates it — can hold onto the command. -func newApplyCommand(t *testing.T, home *configtest.Home) (*cobra.Command, *server.Context) { - t.Helper() - - cmd := server.StartCmd(nil, home.Root, []trace.TracerProviderOption{}) - cmd.SetOut(io.Discard) - cmd.SetErr(io.Discard) - if err := cmd.Flags().Set(flags.FlagHome, home.Root); err != nil { - t.Fatalf("set --home: %v", err) - } - - serverCtx := &server.Context{} - cmd.SetContext(context.WithValue(context.Background(), server.ServerContextKey, serverCtx)) - return cmd, serverCtx -} - -// setFlags applies flag values in sorted key order. -// -// Ranging a map directly would apply them in a different order per run. Every caller here -// sets flags that do not interact, so nothing depends on the order today, but a fuzz -// corpus is only useful if a failing entry reproduces: the first row whose flags interact, -// through cobra validation or one flag's Set reading another, would otherwise fail -// intermittently against the seed that found it. Sorting costs nothing and removes the -// class. -func setFlags(t *testing.T, cmd *cobra.Command, flagValues map[string]string) { - t.Helper() - for _, name := range slices.Sorted(maps.Keys(flagValues)) { - if err := cmd.Flags().Set(name, flagValues[name]); err != nil { - t.Fatalf("set --%s=%q: %v", name, flagValues[name], err) - } - } -} - -// applyThrough runs the legacy manager against a command from newApplyCommand, using -// the node's real template and config struct. -// -// It takes no server.Context: Apply reaches the one it populates through the command's own -// context, set in newApplyCommand, so a parameter here would only imply the context is -// threaded through this call. -func applyThrough(cmd *cobra.Command) error { - template, appConfig := initAppConfig() - return configmanager.LegacyConfigManager{}.Apply(cmd, template, appConfig) -} - -// setServerEnv sets the environment variable the server viper reads for a config -// key, deriving the name the way viper does from the running binary's basename. -func setServerEnv(t *testing.T, key, value string) { - t.Helper() - prefix, err := configtest.ServerEnvPrefix() - if err != nil { - t.Fatalf("resolve env prefix: %v", err) - } - name := configtest.ServerEnvKey(prefix, key) - if err := os.Setenv(name, value); err != nil { - t.Fatalf("set %s: %v", name, err) - } - t.Cleanup(func() { _ = os.Unsetenv(name) }) -} - -// tmKey is a Tendermint config key reachable from all four layers: it has a -// cobra flag, it lives in config.toml, it has an env spelling, and it has an -// in-code default. -type tmKey struct { - // Key is the dotted config.toml key, which for these rows is also the flag - // name and the basis of the env var. - Key string - // Path is the Dump path of the tmcfg.Config field it resolves into. - Path string - // Values are three distinct, individually-valid values for the key, used one - // per layer so the winning layer is identifiable from the result alone. - Values [3]string -} - -// tmKeys are the Tendermint rows the precedence target drives. Each carries three -// distinct legal values so that "which layer won" is readable directly off the -// resolved config. -var tmKeys = []tmKey{ - { - Key: "rpc.laddr", Path: "RPC.ListenAddress", - Values: [3]string{"tcp://127.0.0.1:26610", "tcp://127.0.0.1:26620", "tcp://127.0.0.1:26630"}, - }, - { - Key: "rpc.pprof-laddr", Path: "RPC.PprofListenAddress", - Values: [3]string{"localhost:6010", "localhost:6020", "localhost:6030"}, - }, - { - Key: "p2p.laddr", Path: "P2P.ListenAddress", - Values: [3]string{"tcp://0.0.0.0:26610", "tcp://0.0.0.0:26620", "tcp://0.0.0.0:26630"}, - }, - { - Key: "p2p.persistent-peers", Path: "P2P.PersistentPeers", - Values: [3]string{"a@1.1.1.1:26656", "b@2.2.2.2:26656", "c@3.3.3.3:26656"}, - }, - { - Key: "moniker", Path: "Moniker", - Values: [3]string{"from-file", "from-env", "from-flag"}, - }, -} - -// FuzzHashVaultDisabledUnsafeResolution pins the root-scope kill switch for the -// app-hash equivocation guard. -// -// Two things make it worth its own target. It is a bool whose safe value is the -// default, so an absent key must resolve false — setting it true removes -// equivocation protection with only a log banner. And it lives at TOML root scope, -// before any [section] header: nested under a section it parses as a different key -// and is silently ignored, which reads as "I disabled the guard" while the guard -// stays on, and would read the other way round if the scope were ever mishandled. -// The document is built from the fuzzer's choices rather than taken as free text, -// so the expected outcome follows from construction instead of being a second -// input the fuzzer can mutate out of agreement with the first. -func FuzzHashVaultDisabledUnsafeResolution(f *testing.F) { - f.Add(false, false, false) - f.Add(true, true, false) // root scope, true: the guard is off - f.Add(true, false, false) // root scope, false - f.Add(true, true, true) // nested under a section: silently ignored - f.Add(true, false, true) - - f.Fuzz(func(t *testing.T, present, value, underSection bool) { - configtest.Isolate(t) - home := configtest.NewHome(t) - - var doc strings.Builder - if present { - if underSection { - doc.WriteString("[p2p]\n") - } - fmt.Fprintf(&doc, "hash-vault-disabled-unsafe = %t\n", value) - } - if doc.Len() > 0 { - home.WriteConfigTOML(t, []byte(doc.String())) - } - - // Root scope is the only placement that resolves. Nested under a section the - // key becomes p2p.hash-vault-disabled-unsafe, which nothing reads. - wantDisabled := present && value && !underSection - - got := applyLegacy(t, home, nil) - if got.err != nil { - t.Fatalf("Apply must succeed on a well-formed config.toml, got %v", got.err) - } - if got.ctx.Config.HashVaultDisabledUnsafe != wantDisabled { - t.Fatalf("hash-vault-disabled-unsafe resolved to %v, want %v, from:\n%s", - got.ctx.Config.HashVaultDisabledUnsafe, wantDisabled, doc.String()) - } - }) -} - -// TestHashVaultDisabledUnsafeDefaultsToEnabledGuard states the default on its own, -// so the guard's safe value is pinned even if every seed above were removed. -func TestHashVaultDisabledUnsafeDefaultsToEnabledGuard(t *testing.T) { - configtest.Isolate(t) - got := applyLegacy(t, configtest.NewHome(t), nil) - if got.err != nil { - t.Fatalf("Apply: %v", got.err) - } - if got.ctx.Config.HashVaultDisabledUnsafe { - t.Fatal("an empty home must leave the app-hash equivocation guard enabled") - } -} - -// FuzzApplyPrecedenceTendermint pins the resolution order for Tendermint config: -// flag beats environment beats config.toml beats the in-code default. -// -// The three layers carry three different legal values, so the assertion reads the -// winner straight off serverCtx.Config rather than inferring it. The fuzzer's job -// is to enumerate the presence combinations across every row, including the ones -// nobody writes a hand test for — env set but file absent, flag set with neither, -// all three set at once. -// -// This is the ordering the whole four-layer model in the ConfigManager design -// rests on, and it is currently an emergent property of viper's precedence -// interacting with bindFlags' write-back rather than anything stated in one place. -// Pinning it is what makes it a contract. -func FuzzApplyPrecedenceTendermint(f *testing.F) { - // Every row against every presence combination, generated rather than listed. A plain go test - // run replays seeds and nothing else, and the row index is reduced modulo len(tmKeys), so a - // hand-written list leaves any row it omits unexercised. It did: the list here named rows 0, 3 - // and 4, so rpc.pprof-laddr and p2p.laddr never ran outside a -fuzz session. Generating the - // product means a row added later is driven without anyone remembering to seed it. - // - // It is not free. Each seed runs Isolate and a full Apply that materialises config files, at - // roughly 1.75ms, so this target went from about 0.02s at ten seeds to 0.07s at forty. Against a - // package that runs in about a second that is the trade, and it buys two of the five rows being - // exercised at all. - for row := range len(tmKeys) { - for _, layers := range [][3]bool{ - {false, false, false}, // no layer supplies a value, so the in-code default stands - {true, false, false}, // config.toml alone - {false, true, false}, // environment alone - {false, false, true}, // flag alone - {true, true, false}, // environment beats the file - {true, false, true}, // flag beats the file - {false, true, true}, // flag beats the environment - {true, true, true}, // all three, so the flag must win - } { - f.Add(uint(row), layers[0], layers[1], layers[2]) - } - } - - f.Fuzz(func(t *testing.T, keyIdx uint, inFile, inEnv, inFlag bool) { - configtest.Isolate(t) - row := tmKeys[keyIdx%uint(len(tmKeys))] - home := configtest.NewHome(t) - - // A dotted TOML key is a table path, so one line per key is enough to - // place a value in any section without rendering the section header. - if inFile { - home.WriteConfigTOML(t, []byte(fmt.Sprintf("%s = %q\n", row.Key, row.Values[0]))) - } - if inEnv { - setServerEnv(t, row.Key, row.Values[1]) - } - flagValues := map[string]string{} - if inFlag { - flagValues[row.Key] = row.Values[2] - } - - got := applyLegacy(t, home, flagValues) - if got.err != nil { - t.Fatalf("%s: Apply must succeed with legal values in every layer, got %v", row.Key, got.err) - } - - want := "" - switch { - case inFlag: - want = row.Values[2] - case inEnv: - want = row.Values[1] - case inFile: - want = row.Values[0] - } - - leaf, ok := configtest.LeafAt(configtest.Dump(*got.ctx.Config), row.Path) - if !ok { - t.Fatalf("%s claims to resolve into %q, which is not in the resolved Tendermint config", row.Key, row.Path) - } - if want == "" { - // No layer supplied a value, so the in-code default stands. The default - // itself is not asserted — moniker's is the hostname, which is - // machine-dependent — only that no absent layer leaked a test value in. - for _, v := range row.Values { - if leaf == configtest.DumpAt(row.Path, v) { - t.Fatalf("%s resolved to %s with no layer setting it", row.Key, leaf) - } - } - return - } - if wantLeaf := configtest.DumpAt(row.Path, want); leaf != wantLeaf { - t.Fatalf("%s did not resolve to the highest present layer\n got: %s\nwant: %s\n"+ - "layers: file=%v env=%v flag=%v", row.Key, leaf, wantLeaf, inFile, inEnv, inFlag) - } - }) -} - -// appKey is an app.toml key that also has a cobra flag, resolved through -// serverCtx.Viper rather than through the Tendermint struct. -type appKey struct { - Key string - Values [3]string - // Numeric marks a key whose app.toml spelling is an unquoted TOML integer, so - // the file layer carries a typed scalar rather than a quoted string. - Numeric bool - // WantGoType is the Go type the value has once it reaches appOpts.Get, - // whichever layer supplied it. See FuzzApplyPrecedenceApp for why it is a - // property of the flag's declared type and not of the winning layer. - WantGoType string -} - -var appKeys = []appKey{ - {Key: "pruning", Values: [3]string{"nothing", "everything", "default"}, WantGoType: "string"}, - {Key: "minimum-gas-prices", Values: [3]string{"0.01usei", "0.02usei", "0.03usei"}, WantGoType: "string"}, - {Key: "halt-height", Values: [3]string{"100", "200", "300"}, Numeric: true, WantGoType: "string"}, - {Key: "min-retain-blocks", Values: [3]string{"1000", "2000", "3000"}, Numeric: true, WantGoType: "string"}, - {Key: "grpc.address", Values: [3]string{"127.0.0.1:9010", "127.0.0.1:9020", "127.0.0.1:9030"}, WantGoType: "string"}, - {Key: "state-sync.snapshot-interval", Values: [3]string{"100", "200", "300"}, Numeric: true, WantGoType: "string"}, - // concurrency-workers is registered as an Int flag, which is one of the few - // types viper converts rather than passing through as text. - {Key: "concurrency-workers", Values: [3]string{"4", "8", "16"}, Numeric: true, WantGoType: "int"}, - {Key: "freeze-height", Values: [3]string{"100", "200", "300"}, Numeric: true, WantGoType: "string"}, -} - -// FuzzApplyPrecedenceApp pins the same ordering on the other channel. App -// configuration never becomes a struct during Apply — it stays a flat viper map -// that app.New reads key by key through appOpts.Get — so the assertion is on -// serverCtx.Viper.Get and the comparison is on the rendered value, which keeps the -// resolved *type* in frame. That matters here more than for the Tendermint struct: -// a value that arrives from a flag or an environment variable is a string, while -// the same value from app.toml is a typed TOML scalar, and every downstream -// cast.To* sees the difference. -func FuzzApplyPrecedenceApp(f *testing.F) { - // Every row across every layer combination, so no row's precedence depends on the fuzzer - // being run by hand. Index 6 (concurrency-workers) is the reason this matters beyond - // coverage: it is the only row declaring WantGoType "int", so it is the whole contrast that - // makes the type assertion meaningful. Every other row expects "string", which a build that - // ignored the flag's declared type would also satisfy. - for i := range len(appKeys) { - for _, inFile := range []bool{false, true} { - for _, inEnv := range []bool{false, true} { - for _, inFlag := range []bool{false, true} { - f.Add(uint(i), inFile, inEnv, inFlag) - } - } - } - } - - f.Add(uint(0), false, false, false) - f.Add(uint(0), true, false, false) - f.Add(uint(0), false, true, false) - f.Add(uint(0), false, false, true) - f.Add(uint(0), true, true, true) - f.Add(uint(2), true, true, false) - f.Add(uint(2), true, false, true) - f.Add(uint(4), false, true, true) - f.Add(uint(5), true, true, true) - - f.Fuzz(func(t *testing.T, keyIdx uint, inFile, inEnv, inFlag bool) { - configtest.Isolate(t) - row := appKeys[keyIdx%uint(len(appKeys))] - home := configtest.NewHome(t) - - // app.toml has to exist for this key to come from the file layer, and a - // file that exists is never rewritten, so writing just the one key is - // enough — and is also the shape of an app.toml from an older release. - if inFile { - literal := fmt.Sprintf("%q", row.Values[0]) - if row.Numeric { - literal = row.Values[0] // an unquoted TOML integer, so viper returns int64 - } - home.WriteAppTOML(t, []byte(fmt.Sprintf("%s = %s\n", row.Key, literal))) - } - if inEnv { - setServerEnv(t, row.Key, row.Values[1]) - } - flagValues := map[string]string{} - if inFlag { - flagValues[row.Key] = row.Values[2] - } - - got := applyLegacy(t, home, flagValues) - if got.err != nil { - t.Fatalf("%s: Apply must succeed with legal values in every layer, got %v", row.Key, got.err) - } - - want := "" - switch { - case inFlag: - want = row.Values[2] - case inEnv: - want = row.Values[1] - case inFile: - want = row.Values[0] - } - if want == "" { - return // nothing set; the default is the template's business, not this row's - } - - raw := got.ctx.Viper.Get(row.Key) - if resolved := fmt.Sprintf("%v", raw); resolved != want { - t.Fatalf("%s resolved to %q, want the value from the highest present layer (%q)\n"+ - "layers: file=%v env=%v flag=%v", row.Key, resolved, want, inFile, inEnv, inFlag) - } - - // The resolved Go type is decided by the flag's declared type, not by which - // layer supplied the value, and not by the TOML scalar's own type. - // - // bindFlags copies whatever viper resolved back into the cobra flag for every - // bound flag, which marks it Changed. viper then answers Get from the flag, - // converting only the types its switch names — int and its widths, bool, and - // the slice/map kinds. A uint64 flag falls through to the default branch and - // comes back as the flag's text. So halt-height written as an unquoted TOML - // integer still reaches appOpts.Get as a string, while concurrency-workers - // comes back as an int. - // - // Every downstream reader is a cast.To*, which absorbs the difference — which - // is exactly why this stays invisible until a second manager puts typed values - // in the viper and the differential compares types. - if row.WantGoType != "" { - if got := fmt.Sprintf("%T", raw); got != row.WantGoType { - t.Fatalf("%s resolved to Go type %s (%#v), want %s\n"+ - "layers: file=%v env=%v flag=%v", row.Key, got, raw, row.WantGoType, inFile, inEnv, inFlag) - } - } - }) -} - -// FuzzApplyEnvReachesTheStructOnlyForStructurallyKnownKeys pins the sharpest edge -// in the legacy environment story: one environment variable reaches one boot -// channel and not the other, and which one depends on whether the node has booted -// before. -// -// The server viper runs AutomaticEnv, which resolves at Get time — viper.Get(key) -// consults the environment for any key at all. serverCtx.Config, by contrast, is -// produced by viper.Unmarshal, which only walks keys viper knows structurally: -// those bound to a cobra flag, and those present in a config.toml it actually -// read. Those two sets are not the same, so the channels disagree. -// -// The part no operator could guess is that the set changes on the second boot. -// Creating config.toml and reading config.toml are separate branches: the branch -// that writes a fresh file never reads it back, while the branch that finds an -// existing file calls ReadInConfig. So a key that lives in the rendered template -// but has no flag — p2p.queue-type is one — is invisible to Unmarshal on a fresh -// home and visible on every boot afterwards. The same SEID_P2P_QUEUE_TYPE is -// therefore inert on a node's first start and effective on its restart, while -// being visible to every appOpts.Get() both times. -// -// A replacement manager that resolves every key uniformly diverges here, which is -// a decision to ratify rather than a difference to find in production. -func FuzzApplyEnvReachesTheStructOnlyForStructurallyKnownKeys(f *testing.F) { - f.Add("priority") - f.Add("simple-priority") // the key's own default: the assertion must not rely on inequality - f.Add("fifo") - // An environment variable is bytes, not text, and viper hands whatever it holds - // straight through. Found by the fuzzer; kept as a seed. - f.Add("\xeb") - - f.Fuzz(func(t *testing.T, envValue string) { - if envValue == "" || !configtest.EnvValueIsSettable(envValue) { - return // an empty variable reads as unset, and a NUL cannot be exported - } - configtest.Isolate(t) - - // p2p.queue-type has no cobra flag (neither AddNodeFlags nor - // addStartNodeFlags registers one) and is rendered in the config.toml - // template. That combination is what makes it structurally unknown on a - // fresh home and known on a re-boot. - const key = "p2p.queue-type" - const path = "P2P.QueueType" - - home := configtest.NewHome(t) - setServerEnv(t, key, envValue) - fromEnv := configtest.DumpAt(path, envValue) - - // First boot: config.toml is created and not read back, so the key is - // structurally unknown and Unmarshal cannot see the environment. - first := applyLegacy(t, home, nil) - if first.err != nil { - t.Fatalf("first Apply must succeed, got %v", first.err) - } - firstLeaf, ok := configtest.LeafAt(configtest.Dump(*first.ctx.Config), path) - if !ok { - t.Fatalf("%q is not present in the resolved Tendermint config", path) - } - // A fuzzer that happens to generate the key's own default makes firstLeaf equal - // fromEnv without the environment having reached anything, so that case is exempt. - // The default is read from tmcfg rather than written as a literal: hardcoding it - // couples this row to one spelling, and the row would start failing for real the - // first time the default moved and the fuzzer reached the new value. - defaultLeaf, ok := configtest.LeafAt(configtest.Dump(*tmcfg.DefaultConfig()), path) - if !ok { - t.Fatalf("%q is not present in the default Tendermint config", path) - } - if firstLeaf == fromEnv && firstLeaf != defaultLeaf { - t.Fatalf("on a fresh home the environment reached serverCtx.Config for a flag-less key (%s). "+ - "If the creation branch now reads back the config.toml it wrote, that changes which "+ - "SEID_* variables take effect on a node's first start", firstLeaf) - } - - // Both boots: viper resolves the environment regardless, because - // AutomaticEnv answers at Get time. - if resolved := fmt.Sprintf("%v", first.ctx.Viper.Get(key)); resolved != envValue { - t.Fatalf("serverCtx.Viper.Get(%q) = %q, want the environment value %q", key, resolved, envValue) - } - - // Second boot: config.toml now exists, ReadInConfig runs, the key becomes - // structurally known, and the same variable now moves the struct. - second := applyLegacy(t, home, nil) - if second.err != nil { - t.Fatalf("second Apply must succeed, got %v", second.err) - } - secondLeaf, ok := configtest.LeafAt(configtest.Dump(*second.ctx.Config), path) - if !ok { - t.Fatalf("%q is not present in the resolved Tendermint config", path) - } - // The first boot wrote the template default into config.toml, so when the environment - // carries that same value the file and the environment agree and secondLeaf matches - // whichever one won. That says nothing about the restart property, so it is not - // asserted here. TestFlaglessEnvKeyTakesEffectOnlyAfterTheFirstBoot covers the - // property with a value chosen to differ, so the coverage does not depend on what the - // fuzzer generates. - if fromEnv != defaultLeaf && secondLeaf != fromEnv { - t.Fatalf("on a materialized home the environment must reach serverCtx.Config\n got: %s\nwant: %s", - secondLeaf, fromEnv) - } - if resolved := fmt.Sprintf("%v", second.ctx.Viper.Get(key)); resolved != envValue { - t.Fatalf("serverCtx.Viper.Get(%q) = %q, want the environment value %q", key, resolved, envValue) - } - }) -} - -// FuzzApplyMalformedConfigTOML feeds arbitrary bytes to config.toml. The property -// is total behavior: Apply either succeeds or returns an error, and never panics, -// leaves the process wedged, or reports success on a file it could not read. -// -// It matters because the file is operator-authored and edited under pressure. A -// truncated write, a stray shell heredoc, a half-applied sed — each produces bytes -// like these, and the difference between "the node refuses to start and says why" -// and "the node starts on partially-parsed config" is the difference between an -// outage and a silent misconfiguration. -func FuzzApplyMalformedConfigTOML(f *testing.F) { - f.Add([]byte("")) - f.Add([]byte("moniker = \"ok\"\n")) - f.Add([]byte("moniker = \n")) - f.Add([]byte("[rpc\nladdr = \"x\"\n")) - f.Add([]byte("[[[[[")) - f.Add([]byte("log-level = 42\n")) - f.Add([]byte("log-level = \"not-a-level\"\n")) - f.Add([]byte("mode = \"\"\n")) - f.Add([]byte("rpc.laddr = 1\n")) - f.Add([]byte("\x00\x01\x02")) - f.Add([]byte("moniker = \"a\"\nmoniker = \"b\"\n")) - - f.Fuzz(func(t *testing.T, contents []byte) { - configtest.Isolate(t) - home := configtest.NewHome(t) - home.WriteConfigTOML(t, contents) - - got := applyLegacy(t, home, nil) - - if got.err != nil { - return - } - if got.ctx.Config == nil { - t.Fatal("Apply reported success but left serverCtx.Config nil") - } - if got.ctx.Viper == nil { - t.Fatal("Apply reported success but left serverCtx.Viper nil") - } - // Note what is deliberately *not* asserted: that a successful Apply leaves a - // valid config. It does not, and cannot be made to here — ValidateBasic runs - // only on the file-creation path. See - // TestApplyDoesNotValidateAPreExistingConfigFile. - if got.ctx.Config.RootDir != home.Root { - t.Fatalf("serverCtx.Config.RootDir = %q, want the resolved home %q", got.ctx.Config.RootDir, home.Root) - } - }) -} - -// TestFlaglessEnvKeyTakesEffectOnlyAfterTheFirstBoot pins the restart asymmetry with a -// value chosen to differ from the template default, so the property is exercised on every -// run rather than only when the fuzzer happens to generate a non-default value. -// -// The fuzz target above covers the same key across arbitrary values, but its second-boot -// assertion has to stand down when the generated value equals the default: the first boot -// writes that default into config.toml, so file and environment agree and the resolved value -// no longer says which one won. This row removes that dependence. -func TestFlaglessEnvKeyTakesEffectOnlyAfterTheFirstBoot(t *testing.T) { - configtest.Isolate(t) - - const key = "p2p.queue-type" - const path = "P2P.QueueType" - - defaultLeaf, ok := configtest.LeafAt(configtest.Dump(*tmcfg.DefaultConfig()), path) - if !ok { - t.Fatalf("%q is not present in the default Tendermint config", path) - } - // Two legal queue types, so whichever is the default the probe differs from it. - value := "fifo" - if configtest.DumpAt(path, value) == defaultLeaf { - value = "priority" - } - fromEnv := configtest.DumpAt(path, value) - if fromEnv == defaultLeaf { - t.Fatalf("both probe values match the default (%s); pick another", defaultLeaf) - } - - home := configtest.NewHome(t) - setServerEnv(t, key, value) - - first := applyLegacy(t, home, nil) - if first.err != nil { - t.Fatalf("first Apply: %v", first.err) - } - firstLeaf, ok := configtest.LeafAt(configtest.Dump(*first.ctx.Config), path) - if !ok { - t.Fatalf("%q is not present in the resolved Tendermint config", path) - } - if firstLeaf != defaultLeaf { - t.Fatalf("on a fresh home a flag-less key must resolve its default, not the environment\n"+ - " got: %s\nwant: %s", firstLeaf, defaultLeaf) - } - - second := applyLegacy(t, home, nil) - if second.err != nil { - t.Fatalf("second Apply: %v", second.err) - } - secondLeaf, ok := configtest.LeafAt(configtest.Dump(*second.ctx.Config), path) - if !ok { - t.Fatalf("%q is not present in the resolved Tendermint config", path) - } - if secondLeaf != fromEnv { - t.Fatalf("on a restart the same SEID_* variable must reach serverCtx.Config. This is the "+ - "asymmetry the row exists for: inert on first start, effective on every one after\n"+ - " got: %s\nwant: %s", secondLeaf, fromEnv) - } -} - -// TestApplyDoesNotValidateAPreExistingConfigFile records the validation gap the -// legacy path leaves, and it is the single most important row in this file for the -// ConfigManager work, because closing it is one of the new manager's stated goals. -// -// interceptConfigs calls conf.ValidateBasic() only on the branch that *creates* -// config.toml. When the file already exists it is read, unmarshalled, and handed -// back unvalidated. So a config.toml with `mode = ""` — the shape a half-finished -// edit or a templating bug produces — passes Apply cleanly and takes the node down -// later, from node.New, with an error that points at consensus setup rather than at -// the file. -// -// This is exactly the "silent misconfiguration" class the design proposes to make -// structurally extinct by halting at boot validation with the key named. Pinning -// the current behavior is what lets the new manager's halt be recognized as an -// intentional, ratified divergence rather than a regression. -func TestApplyDoesNotValidateAPreExistingConfigFile(t *testing.T) { - configtest.Isolate(t) - - home := configtest.NewHome(t) - home.WriteConfigTOML(t, []byte("mode = \"\"\n")) - - got := applyLegacy(t, home, nil) - if got.err != nil { - t.Fatalf("legacy Apply must not reject an invalid pre-existing config.toml, got %v", got.err) - } - if err := got.ctx.Config.ValidateBasic(); err == nil { - t.Fatal("mode = \"\" now passes ValidateBasic; the fixture no longer exercises the gap") - } - // Restated as the property, so the test fails if Apply starts validating: - // success from Apply does not imply a bootable config. -} - -// TestApplyMergesAppTOMLAfterUnmarshallingTheTendermintConfig pins the ordering -// inside interceptConfigs, which produces a divergence between the two channels -// that no operator could guess. -// -// config.toml is read and unmarshalled into the Tendermint struct *before* app.toml -// is merged, and both files land in one flat viper namespace where app.toml wins -// collisions. So a key that exists in both files resolves one way in -// serverCtx.Config (config.toml's value, because the struct was already built) and -// the other way in serverCtx.Viper (app.toml's value, because it merged last). -// -// A single app.toml key can therefore shadow a Tendermint setting for every -// appOpts.Get() reader while leaving the Tendermint node itself on the config.toml -// value. -func TestApplyMergesAppTOMLAfterUnmarshallingTheTendermintConfig(t *testing.T) { - configtest.Isolate(t) - - home := configtest.NewHome(t) - home.WriteConfigTOML(t, []byte("moniker = \"from-config-toml\"\n")) - home.WriteAppTOML(t, []byte("moniker = \"from-app-toml\"\n")) - - got := applyLegacy(t, home, nil) - if got.err != nil { - t.Fatalf("Apply: %v", got.err) - } - - if got.ctx.Config.Moniker != "from-config-toml" { - t.Errorf("serverCtx.Config.Moniker = %q, want config.toml's value: the struct is "+ - "unmarshalled before app.toml is merged", got.ctx.Config.Moniker) - } - if resolved := fmt.Sprintf("%v", got.ctx.Viper.Get("moniker")); resolved != "from-app-toml" { - t.Errorf("serverCtx.Viper.Get(\"moniker\") = %q, want app.toml's value: both files "+ - "share one flat namespace and app.toml is merged last", resolved) - } -} - -// FuzzApplyMalformedAppTOML feeds arbitrary bytes to app.toml. The extra property -// on this side is that app.toml and config.toml share one flat viper namespace — -// app.toml is merged into the same instance that already holds config.toml, and -// wins on collisions — so a malformed app.toml can only fail the merge, never -// silently reshape the Tendermint config that was already unmarshalled. -func FuzzApplyMalformedAppTOML(f *testing.F) { - f.Add([]byte("")) - f.Add([]byte("halt-height = 1\n")) - f.Add([]byte("halt-height = \n")) - f.Add([]byte("freeze-height = 1\n")) - f.Add([]byte("[telemetry\nenabled = true\n")) - f.Add([]byte("moniker = \"app-toml-wins\"\n")) - f.Add([]byte("telemetry.global-labels = \"not-a-list\"\n")) - f.Add([]byte("\xff\xfe\x00")) - f.Add([]byte("[[[[[")) - - f.Fuzz(func(t *testing.T, contents []byte) { - configtest.Isolate(t) - home := configtest.NewHome(t) - home.WriteAppTOML(t, contents) - - got := applyLegacy(t, home, nil) - - if got.err != nil { - return - } - if got.ctx.Config == nil || got.ctx.Viper == nil { - t.Fatal("Apply reported success but left a boot channel nil") - } - // app.toml is merged after the Tendermint struct is built, so no app.toml - // content can make the struct invalid — whatever these bytes contain, the - // struct came from config.toml (here: the freshly created defaults). - if err := got.ctx.Config.ValidateBasic(); err != nil { - t.Fatalf("app.toml content must not be able to invalidate the Tendermint config, got %v", err) - } - }) -} - -// FuzzApplyIsIdempotent pins the property that makes every other assertion in -// this file meaningful: booting the same home twice resolves to the same thing. -// -// It is not trivially true. The first Apply on a fresh home *writes* both files — -// config.toml with hardcoded values that DefaultConfig does not carry, and -// app.toml rendered from whatever the viper held at that moment — and the second -// Apply reads what the first one wrote. So this target is really asking whether -// materialization is a fixed point, which is what lets a node restart without its -// configuration drifting, and what lets the differential harness compare two -// managers on a fixture home at all. -// -// The freshly-generated app.toml carries a randomized pruning-interval and a -// hostname-derived moniker, so the comparison is deliberately between run two and -// run three (both of which read files rather than writing them) with run one -// serving only to materialize. -func FuzzApplyIsIdempotent(f *testing.F) { - f.Add(false, false, "") - f.Add(true, false, "") - f.Add(false, true, "") - f.Add(true, true, "") - f.Add(true, true, "tcp://127.0.0.1:26656") - - f.Fuzz(func(t *testing.T, seedConfig, seedApp bool, p2pLaddr string) { - configtest.Isolate(t) - home := configtest.NewHome(t) - if seedConfig { - home.WriteConfigTOML(t, []byte("moniker = \"fixture\"\n")) - } - if seedApp { - home.WriteAppTOML(t, []byte("halt-height = 7\n")) - } - flagValues := map[string]string{} - if p2pLaddr != "" { - flagValues["p2p.laddr"] = p2pLaddr - } - - if first := applyLegacy(t, home, flagValues); first.err != nil { - // The laddr is the only input here the fuzzer can make unusable, so with none set - // there is nothing to attribute a failure to and materializing has to succeed. - // Skipping in that case would let a regression that stops a seeded row from - // materializing pass as a skip, which is the outcome this suite exists to prevent. - // A non-empty laddr still declines, the same move IsTOMLWritable makes, rather than - // re-deriving what the p2p layer accepts. - if p2pLaddr == "" { - t.Fatalf("materializing boot with no laddr override must succeed: %v", first.err) - } - t.Skipf("materializing boot failed for laddr %q (%v); malformed-input behavior is covered elsewhere", - p2pLaddr, first.err) - } - if !home.Exists("config.toml") || !home.Exists("app.toml") { - t.Fatal("the first Apply must leave both config.toml and app.toml on disk") - } - - second := applyLegacy(t, home, flagValues) - third := applyLegacy(t, home, flagValues) - if second.err != nil || third.err != nil { - t.Fatalf("re-booting a materialized home must succeed: %v / %v", second.err, third.err) - } - - if a, b := configtest.Dump(*second.ctx.Config), configtest.Dump(*third.ctx.Config); a != b { - t.Fatalf("serverCtx.Config differs between two boots of the same home\n--- second\n%s\n--- third\n%s", a, b) - } - if a, b := configtest.DumpViper(second.ctx.Viper), configtest.DumpViper(third.ctx.Viper); a != b { - t.Fatalf("serverCtx.Viper differs between two boots of the same home\n--- second\n%s\n--- third\n%s", a, b) - } - }) -} - -// TestApplyMaterializationOverridesOnlyApplyToACreatedConfigFile pins one of the -// legacy path's least obvious behaviors, and one with a real operational -// consequence. -// -// When config.toml is absent, interceptConfigs writes it after stamping three -// values that neither tmcfg.DefaultConfig nor the template carries: a pprof -// listener on localhost:6060 and P2P receive and send rates of 5120000. When -// config.toml is present, those stampings do not happen — the file is simply read. -// -// So two nodes on the same binary run different RPC and P2P settings depending -// only on whether their config.toml was generated by this code path or by an -// earlier one. Deleting and regenerating a config.toml changes node behavior, -// which is the opposite of what "regenerate the defaults" implies. -func TestApplyMaterializationOverridesOnlyApplyToACreatedConfigFile(t *testing.T) { - configtest.Isolate(t) - - created := configtest.NewHome(t) - if got := applyLegacy(t, created, nil); got.err != nil { - t.Fatalf("Apply on an empty home: %v", got.err) - } - generated := applyLegacy(t, created, nil) - if generated.err != nil { - t.Fatalf("re-Apply on the materialized home: %v", generated.err) - } - - // A config.toml that exists but says nothing: the same binary, the same - // absent keys, and none of the creation-path stampings. - preexisting := configtest.NewHome(t) - preexisting.WriteConfigTOML(t, []byte("# authored by an earlier release\n")) - read := applyLegacy(t, preexisting, nil) - if read.err != nil { - t.Fatalf("Apply on a home with a minimal config.toml: %v", read.err) - } - - checks := []struct { - what string - fromGenerated any - fromRead any - }{ - {"RPC.PprofListenAddress", generated.ctx.Config.RPC.PprofListenAddress, read.ctx.Config.RPC.PprofListenAddress}, - {"P2P.RecvRate", generated.ctx.Config.P2P.RecvRate, read.ctx.Config.P2P.RecvRate}, - {"P2P.SendRate", generated.ctx.Config.P2P.SendRate, read.ctx.Config.P2P.SendRate}, - } - for _, c := range checks { - if fmt.Sprintf("%v", c.fromGenerated) == fmt.Sprintf("%v", c.fromRead) { - t.Errorf("%s no longer distinguishes a generated config.toml from a pre-existing one "+ - "(both %v). If the creation-path override was moved into DefaultConfig or the "+ - "template on purpose, that changes behavior for every existing node and needs a "+ - "migration, not just an updated test", c.what, c.fromGenerated) - } - } -} - -// TestServerEnvPrefixFollowsExecutableBasename pins the environment prefix to -// path.Base(os.Executable()) rather than to the literal "seid". -// -// This is the mechanism behind a genuinely surprising failure mode: renaming or -// symlinking the binary silently changes every environment variable the node -// responds to, so a deployment that invokes the node as `sei-node` ignores every -// SEID_* variable it is given, with no warning. The assertion is written as a -// relationship rather than a constant precisely so it holds inside a test binary -// — which is itself a differently-named executable, and therefore a live -// demonstration of the edge. -func TestServerEnvPrefixFollowsExecutableBasename(t *testing.T) { - configtest.Isolate(t) - - prefix, err := configtest.ServerEnvPrefix() - if err != nil { - t.Fatalf("resolve env prefix: %v", err) - } - if prefix == "seid" { - t.Skip("test binary is named seid; the prefix relationship is not observable here") - } - - const key = "rpc.laddr" - const want = "tcp://127.0.0.1:26699" - const ignored = "tcp://127.0.0.1:26698" - - derivedName := configtest.ServerEnvKey(prefix, key) - seidName := configtest.ServerEnvKey("seid", key) - if seidName == derivedName { - t.Fatalf("derived prefix %q collides with seid; cannot distinguish the two spellings", prefix) - } - - // The baseline is resolved with neither variable set, so the negative half below can - // assert an actual fallback rather than merely "not the value I set". - baseline := applyLegacy(t, configtest.NewHome(t), nil) - if baseline.err != nil { - t.Fatalf("Apply: %v", baseline.err) - } - unset := baseline.ctx.Config.RPC.ListenAddress - if unset == want || unset == ignored { - t.Fatalf("fixture default %q collides with a probe value; pick different probes", unset) - } - - // The derived name is honored... - setServerEnv(t, key, want) - got := applyLegacy(t, configtest.NewHome(t), nil) - if got.err != nil { - t.Fatalf("Apply: %v", got.err) - } - if got.ctx.Config.RPC.ListenAddress != want { - t.Fatalf("%s did not take effect; resolved %q, want %q", - derivedName, got.ctx.Config.RPC.ListenAddress, want) - } - - // ...and the "seid" spelling is not, because this binary is not named seid. Asserted by - // resolving it rather than by comparing the two names: that the spellings differ says - // nothing about which one Apply reads, so the derived variable is cleared and the seid - // one set alone. - if err := os.Unsetenv(derivedName); err != nil { - t.Fatalf("unset %s: %v", derivedName, err) - } - if err := os.Setenv(seidName, ignored); err != nil { - t.Fatalf("set %s: %v", seidName, err) - } - fresh := applyLegacy(t, configtest.NewHome(t), nil) - if fresh.err != nil { - t.Fatalf("Apply: %v", fresh.err) - } - if fresh.ctx.Config.RPC.ListenAddress != unset { - t.Fatalf("with only %s set the address resolved to %q, want the unset baseline %q. The "+ - "prefix is the literal seid rather than the executable basename %q, which would mean "+ - "a renamed binary keeps responding to SEID_* after all", - seidName, fresh.ctx.Config.RPC.ListenAddress, unset, prefix) - } -} - -// TestGeneratedAppTOMLDivergesFromTheWasmInCodeDefault pins the [wasm] gas divergence -// against the template seid actually renders. -// -// query_gas_limit is one of the few keys the template writes as a bare literal rather than -// a {{ .Field }} substitution, so the number lives in this package's template string and -// nothing derives it from wasmd's defaults. The consequence is the finding: a node whose -// app.toml seid generated runs smart queries at a tenth of the allowance of a node whose -// app.toml has no [wasm] section, and neither node looks misconfigured by its own file. -// -// The row belongs here rather than beside the reader. A test in the wasm package can only -// hand the reader a number and watch it come back, which proves the reader echoes its -// input and would stay green if the template changed. This reads what a fresh home -// materializes, so editing the literal in the template moves this assertion. -func TestGeneratedAppTOMLDivergesFromTheWasmInCodeDefault(t *testing.T) { - configtest.Isolate(t) - home := configtest.NewHome(t) - - // The expected value is stated here and compared against a real generated file, so it is - // an expectation rather than the echo it would be if it were fed to the reader. - const generatedLiteral = uint64(300000) - - got := applyLegacy(t, home, nil) - if got.err != nil { - t.Fatalf("Apply: %v", got.err) - } - if !home.Exists("app.toml") { - t.Fatal("Apply did not materialize app.toml, so this row is not reading a generated file") - } - raw := got.ctx.Viper.Get("wasm.query_gas_limit") - if raw == nil { - t.Fatal("a generated app.toml no longer carries wasm.query_gas_limit. If the key left the " + - "template, every generated node now runs smart queries at wasmd's in-code default " + - "instead, which raises the allowance tenfold") - } - fromTemplate, castErr := cast.ToUint64E(raw) - if castErr != nil { - t.Fatalf("wasm.query_gas_limit = %#v does not convert to uint64: %v", raw, castErr) - } - if fromTemplate != generatedLiteral { - t.Fatalf("a generated app.toml resolves wasm.query_gas_limit to %d, and this row expects "+ - "%d. The template literal moved: that changes the gas allowance on every node generated "+ - "from it, so update this row deliberately rather than to make it pass", - fromTemplate, generatedLiteral) - } - - inCode := wasmtypes.DefaultWasmConfig().SmartQueryGasLimit - if fromTemplate == inCode { - t.Fatalf("the template literal and wasmd's in-code default are both %d. Closing that "+ - "divergence changes what contract queries succeed on every node whose app.toml lacks "+ - "[wasm], so it is recorded here rather than skipped past", inCode) - } - if fromTemplate >= inCode { - t.Fatalf("a generated app.toml (%d) is no longer tighter than the in-code default (%d); the "+ - "direction of the divergence changed", fromTemplate, inCode) - } -} - -// TestGeneratedAppTOMLUsesTheSpellingsTheReadersLookUp pins that the template's section -// headers and key names match the keys the section readers actually resolve. -// -// The template writes headers and key names as literal text and resolves only values through -// {{ .Field }}, so the spellings are independent of the mapstructure tags on -// CustomAppConfig. Where the two disagree, the tag is the inert one: -// CustomAppConfig.ETHBlockTest is tagged eth_block_test while both the template and the -// reader use eth_blocktest. That divergence is harmless today precisely because generation -// does not consult the tags, which is the property this row holds. A manager that generated -// config from the struct tags instead would emit sections the readers ignore, and this is -// where that shows up. -func TestGeneratedAppTOMLUsesTheSpellingsTheReadersLookUp(t *testing.T) { - configtest.Isolate(t) - home := configtest.NewHome(t) - - got := applyLegacy(t, home, nil) - if got.err != nil { - t.Fatalf("Apply: %v", got.err) - } - - // Keys a reader looks up that must be present in the resolved view of a generated file, - // alongside the tag spelling that must not be. - for _, key := range []string{ - "eth_blocktest.eth_blocktest_enabled", - "eth_blocktest.eth_blocktest_test_data_path", - } { - if got.ctx.Viper.Get(key) == nil { - t.Fatalf("a generated app.toml does not carry %q, which the section reader looks up. "+ - "If the template's spelling changed, every generated node now resolves this key's "+ - "default instead of the file's value", key) - } - } - if v := got.ctx.Viper.Get("eth_block_test.eth_blocktest_enabled"); v != nil { - t.Fatalf("a generated app.toml now carries the mapstructure spelling eth_block_test (%#v). "+ - "Generation has started following the struct tags, and the reader looks up "+ - "eth_blocktest, so the section it writes is ignored", v) - } -} - -// TestStateCommitAsyncCommitBufferTagAddressesNoReader records a second inert mapstructure -// tag, and one with a sharper edge than eth_block_test's. -// -// Two fields carry the tag async-commit-buffer: StateCommitConfig.AsyncCommitBuffer, which -// nothing reads, and memiavl.Config.AsyncCommitBuffer, which decides whether a node commits -// synchronously. MemIAVLConfig carries no tag, so a tag-driven binder would reach the live -// field only at state-commit.memiavlconfig.async-commit-buffer while the dead one sits at the -// shallower state-commit.async-commit-buffer — and sc-async-commit-buffer, the spelling the -// template renders and both readers resolve, would address no field at all. So the shallower -// spelling shadows the live knob: an operator correcting an app.toml to the tag-advertised name -// has their value land in the dead field, and what the node then commits with depends on -// something the tags do not say. A binder that unmarshals over DefaultStateCommitConfig leaves -// the live buffer at 100; one that unmarshals into a zero struct leaves it at 0, which memiavl -// reads as synchronous commit. Neither outcome is the value the operator wrote. -// -// Recorded here rather than repaired: retagging either field changes what a tag-driven -// manager binds, and PLT-775 is where that is chosen. The binder is not hypothetical — -// sei-cosmos/server/util.go:302 unmarshals the root viper into the custom app config on the -// app.toml-absent branch, and reaches the dead field only once some layer makes -// state-commit.async-commit-buffer a key that viper holds. What this holds is that the divergence -// stays the one described above — the dead field keeps the tag the live field also carries, -// and neither is addressable at the spelling the readers use. -func TestStateCommitAsyncCommitBufferTagAddressesNoReader(t *testing.T) { - const ( - tag = "async-commit-buffer" - readerName = "sc-async-commit-buffer" - ) - tagOf := func(structType reflect.Type, field string) string { - t.Helper() - f, ok := structType.FieldByName(field) - if !ok { - t.Fatalf("%s has no field %s; this recording names fields that no longer exist", - structType, field) - } - return f.Tag.Get("mapstructure") - } - - if got := tagOf(reflect.TypeOf(seidbconfig.StateCommitConfig{}), "AsyncCommitBuffer"); got != tag { - t.Errorf("StateCommitConfig.AsyncCommitBuffer is now tagged %q, was %q. That field is read by "+ - "nothing, so retagging it to %q would make the dead field the one a tag-driven manager "+ - "binds under the spelling operators write, and the live memiavl field unreachable. If the "+ - "field was deleted or the collision closed, this recording is what has to change with it", - got, tag, readerName) - } - if got := tagOf(reflect.TypeOf(memiavl.Config{}), "AsyncCommitBuffer"); got != tag { - t.Errorf("memiavl.Config.AsyncCommitBuffer is now tagged %q, was %q. This is the live field: "+ - "<= 0 means synchronous commit. Both readers reach it through %q, which is a literal in "+ - "the app.toml template and in each reader, so moving the tag changes only what a "+ - "tag-driven binder addresses — and that is the change worth reviewing on its own", - got, tag, readerName) - } - if got := tagOf(reflect.TypeOf(seidbconfig.StateCommitConfig{}), "MemIAVLConfig"); got != "" { - t.Errorf("StateCommitConfig.MemIAVLConfig is now tagged %q, where it carried no tag. That "+ - "moves the live async-commit-buffer to state-commit.%s.%s for a tag-driven binder, and "+ - "whether it now shadows or is shadowed by the dead field is the whole of the review", - got, got, tag) - } - - // The live anchor: what a generated app.toml actually carries is the readers' spelling, and - // not the tag's. Without it the assertions above would hold equally in a tree where - // generation had started following the tags. - configtest.Isolate(t) - got := applyLegacy(t, configtest.NewHome(t), nil) - if got.err != nil { - t.Fatalf("Apply: %v", got.err) - } - if v := got.ctx.Viper.Get("state-commit." + readerName); v == nil { - t.Fatalf("a generated app.toml no longer carries state-commit.%s, which both readers look "+ - "up. Every app.toml on disk addresses that spelling, so the async commit queue on every "+ - "node just fell back to its in-code default", readerName) - } - if v := got.ctx.Viper.Get("state-commit." + tag); v != nil { - t.Fatalf("a generated app.toml now carries state-commit.%s (%#v). Generation has started "+ - "following the struct tags, and that spelling reaches the field nothing reads, so the "+ - "key an operator sets no longer changes how the node commits", tag, v) - } -} - -// TestKeyNamesMatchTheRecordedNames records the two [state-sync] keys whose constant nothing -// else in the tree holds. -// -// NewApp reads all three state-sync keys through the constants declared in -// sei-cosmos/server/start.go — `appOpts.Get(server.FlagStateSyncSnapshotDir)` at root.go:255 and -// the three baseapp.Set* calls at root.go:304-306. It is not the only reader, and the readers -// that name the keys name them as literals: sei-cosmos/server/config.GetConfig reads all three -// into StateSyncConfig at config.go:615-619, and the app.toml template writes all three as -// literal text at sei-cosmos/server/config/toml.go:76, :79 and :83. Those namings are what make -// this record necessary rather than redundant: a constant rename moves NewApp and leaves every -// literal where it was, so the suite stays green, the readers now disagree about which key they -// resolve, and the key an operator wrote reaches only one of them. -// -// A third reader names them neither way: ParseConfig (sei-cosmos/server/config/toml.go:272-277) -// unmarshals the section by mapstructure tag, so a tag rename moves it and leaves both the -// literals and the constants where they were — the mirror of the case above, and the same seam -// TestStateCommitAsyncCommitBufferTagAddressesNoReader records for [state-commit]. It reaches -// seid only on util.go:308's empty-template branch, and initAppConfig always supplies a template -// (root.go:426 assembles it, non-empty), so nothing here holds it. That unreachability is prose -// rather than an assertion: the branch goes live the moment any caller passes an empty template. -// -// snapshot-interval is the exception, and by accident of spelling rather than by design: appKeys -// above names it as a literal, so it does not move when the constant moves and a constant-only -// rename fails six seeds of FuzzApplyPrecedenceApp. The other two have no such assertion, so -// editing the constant renames an operator-facing key with this whole suite green. -// -// What each rename costs is why they are worth a record rather than a deferral: -// -// - snapshot-keep-recent is a registered flag defaulting to 2 (start.go:234), so a rename does -// not surface as a missing flag — it silently reverts an explicit `= 10` to 2, and the serving -// node prunes snapshots a joining node is part-way through downloading. It presents on the -// joining node, as state-sync failure against a serving node that looks healthy. -// - snapshot-directory is registered nowhere, so a rename silently drops an explicit path and -// snapshots land in $HOME/data/snapshots instead (root.go:255-257). It presents as disk -// pressure on whichever volume the home directory is on. -// -// They are recorded with no rows because a row predicts a resolved leaf and NewApp builds a whole -// baseapp against a materialized node directory rather than resolving an AppOpts into a struct. -// That is a property of this reader and not of the section: GetConfig's reading of the same three -// keys is describable and is described, by a three-row manifest and a state-sync record of its own -// in sei-cosmos/server/config. Here a KeyName claims the spelling and nothing else, which is what -// can be said truthfully about NewApp. The consequence is that no seeds check ties this record — -// that tie needs a manifest — so this call is the only thing holding it, unlike the thirteen -// sections where CheckEveryRowHasADiscriminatingSeed compares the record as well. -func TestKeyNamesMatchTheRecordedNames(t *testing.T) { - configtest.CheckKeyNames(t, "state-sync", nil, - server.FlagStateSyncSnapshotKeepRecent, - server.FlagStateSyncSnapshotDir) -} - -// TestTendermintKeyNamesMatchTheRecordedNames pins the operator-facing spelling of the five -// Tendermint keys FuzzApplyPrecedenceTendermint drives. -// -// tmKeys carries a local struct rather than a KeySpec table, because a precedence row needs three -// distinct legal values and a KeySpec has nowhere to put them. The consequence is that no manifest -// check reaches these keys, so their spelling had nothing holding it. A KeyName claims the spelling -// and nothing else, which is the same thing the state-sync record above does and for the same -// reason. -// -// Read from tmKeys rather than listed here, so a row added later is recorded without anyone -// remembering this call. -func TestTendermintKeyNamesMatchTheRecordedNames(t *testing.T) { - names := make([]configtest.KeyName, 0, len(tmKeys)) - for _, row := range tmKeys { - names = append(names, configtest.KeyName(row.Key)) - } - configtest.CheckKeyNames(t, "tendermint", nil, names...) -} - -// TestApplyLeavesBothChannelsPopulated states the seam's minimum contract, the one -// the ConfigManager interface documents: whichever manager runs, both channels -// come back populated. It is the assertion a new manager fails first. -func TestApplyLeavesBothChannelsPopulated(t *testing.T) { - configtest.Isolate(t) - home := configtest.NewHome(t) - - got := applyLegacy(t, home, nil) - if got.err != nil { - t.Fatalf("Apply on an empty fixture home must succeed, got %v", got.err) - } - if got.ctx.Config == nil { - t.Fatal("serverCtx.Config is nil") - } - if got.ctx.Viper == nil { - t.Fatal("serverCtx.Viper is nil") - } - if got.ctx.Config.RootDir != home.Root { - t.Fatalf("serverCtx.Config.RootDir = %q, want the resolved home %q", got.ctx.Config.RootDir, home.Root) - } - // The viper must carry the app sections app.New reads, not just tendermint keys. - for _, key := range []string{ - "state-commit.sc-enable", - "state-store.ss-enable", - "evm.http_enabled", - "giga_executor.enabled", - "admin_server.admin_enabled", - } { - if got.ctx.Viper.Get(key) == nil { - t.Errorf("serverCtx.Viper is missing %q, which app.New reads through appOpts.Get", key) - } - } -} - -// TestWiringMatchesTheRecord pins which checks each of this package's sections is wired to. -// -// Every other check here reports a change to what it asserts. None reports a check being removed, so -// this records the wiring and fails when it thins out. -func TestWiringMatchesTheRecord(t *testing.T) { - configtest.CheckWiring(t) -} diff --git a/sei-cosmos/server/config/config_fuzz_test.go b/sei-cosmos/server/config/config_fuzz_test.go deleted file mode 100644 index e77c9022d1..0000000000 --- a/sei-cosmos/server/config/config_fuzz_test.go +++ /dev/null @@ -1,1528 +0,0 @@ -package config - -import ( - "fmt" - "reflect" - "runtime" - "sort" - "strings" - "testing" - "time" - - "github.com/sei-protocol/sei-chain/sei-db/config" - sctypes "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/types" - "github.com/sei-protocol/sei-chain/testutil/configtest" - "github.com/sei-protocol/sei-chain/testutil/fuzzing" - "github.com/spf13/viper" -) - -// GetConfig is the second parser of app.toml, and the one that feeds api, grpc, -// grpc-web, rosetta, telemetry and state-sync. app/seidb.go parses [state-commit] -// and [state-store] out of the same viper for the store; GetConfig parses them -// again, by a different mechanism (viper.IsSet plus typed getters rather than -// appOpts.Get plus cast), into a Config nobody hands to the store. -// -// Two parsers of one section is the drift risk the manifest names, and the reason -// this file exists: it pins GetConfig's own resolution rules so a change that -// unified the parsers would show up as a diff here rather than as two components -// disagreeing about the same key at runtime. -// -// Everything below drives a bare viper.New, which is what GetConfig takes. That -// deliberately excludes flag binding and env resolution — those belong to Apply and -// are pinned in cmd/seid/cmd. What is left is the parse itself. - -// newAppViper returns a viper holding the one key GetConfig unconditionally -// requires, plus whatever the caller adds. telemetry.global-labels has no default -// and no presence guard, so nothing can be parsed without it. -func newAppViper(t testing.TB, keys map[string]any) *viper.Viper { - t.Helper() - v := viper.New() - v.Set("telemetry.global-labels", []any{}) - for k, val := range keys { - v.Set(k, val) - } - return v -} - -// FuzzGetConfigGlobalLabels pins the one key that can stop a node booting by being -// absent rather than wrong. -// -// telemetry.global-labels is read as a bare type assertion to []interface{} with no -// presence check, so an app.toml that omits the key entirely fails GetConfig -// outright — a node provisioned before the key existed does not start. Inside, each -// label is asserted to []interface{} and then its two elements to string with no -// checked assertion, so a label list holding a non-string panics rather than -// erroring. -// -// The shape rules are otherwise permissive in a way no operator would guess: a -// label whose length is not exactly 2 is silently dropped, not rejected. -func FuzzGetConfigGlobalLabels(f *testing.F) { - f.Add(0, 0, "chain") // no labels - f.Add(1, 2, "chain") // one well-formed pair - f.Add(3, 2, "chain") // several pairs - f.Add(1, 1, "chain") // one element: silently dropped - f.Add(1, 3, "chain") // three elements: silently dropped - f.Add(1, 0, "chain") // empty label - f.Add(2, 2, "") // empty strings are legal label content - f.Add(1, 2, "a=b,c=d") // punctuation is not special - - f.Fuzz(func(t *testing.T, labelCount, elemsPerLabel int, content string) { - // Keep the generated document small; the shape rules are what matter. - if labelCount < 0 || labelCount > 8 || elemsPerLabel < 0 || elemsPerLabel > 4 { - return - } - - labels := make([]any, 0, labelCount) - for i := range labelCount { - elems := make([]any, 0, elemsPerLabel) - for j := range elemsPerLabel { - elems = append(elems, fmt.Sprintf("%s-%d-%d", content, i, j)) - } - labels = append(labels, elems) - } - - v := viper.New() - v.Set("telemetry.global-labels", labels) - cfg, err := GetConfig(v) - if err != nil { - t.Fatalf("a well-typed global-labels list must parse, got %v", err) - } - - // Only two-element labels survive; the rest vanish without a diagnostic. - wantKept := 0 - if elemsPerLabel == 2 { - wantKept = labelCount - } - if len(cfg.Telemetry.GlobalLabels) != wantKept { - t.Fatalf("%d labels of %d elements resolved to %d kept, want %d "+ - "(a label whose length is not exactly 2 is dropped silently)", - labelCount, elemsPerLabel, len(cfg.Telemetry.GlobalLabels), wantKept) - } - }) -} - -// TestGetConfigRequiresGlobalLabels pins the absent-key failure on its own. This is -// the row that turns a missing telemetry section into a node that will not start. -func TestGetConfigRequiresGlobalLabels(t *testing.T) { - _, err := GetConfig(viper.New()) - if err == nil { - t.Fatal("an app.toml with no telemetry.global-labels must fail GetConfig; " + - "if a presence guard was added, that changes which existing app.toml files boot") - } - if !strings.Contains(err.Error(), "global-labels") { - t.Fatalf("the failure must name the key, got %v", err) - } -} - -// TestGetConfigPanicsOnNonStringLabel records that the inner element assertions are -// unchecked. A label list of the right shape but the wrong element type takes the -// node down with a raw interface-conversion panic rather than an error naming -// telemetry. -func TestGetConfigPanicsOnNonStringLabel(t *testing.T) { - v := viper.New() - v.Set("telemetry.global-labels", []any{[]any{1, 2}}) - - defer func() { - if r := recover(); r == nil { - t.Fatal("a non-string label must panic; if it is now an error, the diagnostic " + - "improved and this row should say so") - } - }() - _, _ = GetConfig(v) -} - -// grpcClamp is a duration key GetConfig clamps rather than accepts verbatim. -type grpcClamp struct { - Key string - Path string - Default time.Duration -} - -var grpcClamps = []grpcClamp{ - {Key: "grpc.max-connection-idle", Path: "GRPC.MaxConnectionIdle", Default: DefaultGRPCMaxConnectionIdle}, - {Key: "grpc.keepalive-time", Path: "GRPC.KeepaliveTime", Default: DefaultGRPCKeepaliveTime}, - {Key: "grpc.keepalive-timeout", Path: "GRPC.KeepaliveTimeout", Default: DefaultGRPCKeepaliveTimeout}, - {Key: "grpc.keepalive-min-time", Path: "GRPC.KeepaliveMinTime", Default: DefaultGRPCKeepaliveMinTime}, - {Key: "grpc.max-connection-age", Path: "GRPC.MaxConnectionAge", Default: DefaultGRPCMaxConnectionAge}, - { - Key: "grpc.max-connection-age-grace", Path: "GRPC.MaxConnectionAgeGrace", - Default: DefaultGRPCMaxConnectionAgeGrace, - }, -} - -// FuzzGetConfigGRPCDurationClamps pins the negative-duration clamp on the gRPC -// keepalive keys. -// -// gRPC accepts a negative keepalive verbatim and behaves pathologically, so GetConfig -// substitutes the in-code default instead of passing it through. Only a negative is -// clamped, uniformly across all six keys: zero passes through everywhere, which matters -// most on the two age keys where gRPC reads zero as "no limit". Distinguishing negative -// from zero rather than treating both as unset is what this target holds in place. -// -// Each value is driven in two shapes, because a typed time.Duration is not a shape any -// app.toml can produce. A file gives "30s" or a bare integer and an environment variable -// always gives a string, so the typed form skips the cast.ToDuration step that sits between -// the file layer and this comparison. The string spelling is the one an operator actually -// writes, and "-1s" is how they would express the boundary that matters here. -func FuzzGetConfigGRPCDurationClamps(f *testing.F) { - f.Add(uint(0), int64(0), false) - f.Add(uint(0), int64(-1), false) - f.Add(uint(0), int64((30 * time.Second)), false) - f.Add(uint(4), int64(0), false) - f.Add(uint(4), int64(-1), false) - f.Add(uint(1), int64(-1000000000), false) - f.Add(uint(2), int64((time.Hour)), false) - // The same values as an operator would write them. - f.Add(uint(0), int64((30 * time.Second)), true) - f.Add(uint(0), int64(-1000000000), true) // "-1s" - f.Add(uint(4), int64(0), true) - f.Add(uint(2), int64((time.Hour)), true) - - f.Fuzz(func(t *testing.T, keyIdx uint, nanos int64, asString bool) { - row := grpcClamps[keyIdx%uint(len(grpcClamps))] - d := time.Duration(nanos) - - // A typed duration and its own String() spelling must resolve identically, since - // viper.GetDuration casts the text back. Any divergence is in cast, not in the clamp, - // and it belongs to this row because the file layer only ever produces the text. - var raw any = d - if asString { - // Only a spelling that parses back to the same duration is a faithful stand-in for - // the typed value. Duration.String has not always round-tripped at the int64 - // boundary, and a spelling the parser rejects would resolve to zero and fail this - // row with a message about the clamp rather than about the encoding, so a value - // with no faithful text form is declined instead. - spelled := d.String() - if back, perr := time.ParseDuration(spelled); perr != nil || back != d { - return // no faithful text spelling; the typed shape already covers this value - } - raw = spelled - } - - cfg, err := GetConfig(newAppViper(t, map[string]any{row.Key: raw})) - if err != nil { - t.Fatalf("%s = %#v must parse, got %v", row.Key, raw, err) - } - - want := d - if d < 0 { - want = row.Default - } - got, ok := configtest.LeafAt(configtest.Dump(cfg), row.Path) - if !ok { - t.Fatalf("%s resolves into %q, which is not in the parsed config", row.Key, row.Path) - } - if wantLeaf := configtest.DumpAt(row.Path, want); got != wantLeaf { - t.Fatalf("%s = %#v resolved wrongly\n got: %s\nwant: %s\n"+ - "a negative duration falls back to the in-code default; zero passes through, and "+ - "the typed and string spellings must agree", - row.Key, raw, got, wantLeaf) - } - }) -} - -// FuzzGetConfigWriteMode pins GetConfig's own copy of the write-mode resolution. -// -// The rules match app/seidb.go — always parse, then let sc-write-mode-enable-auto -// (default true, flipped only by an explicit key) decide whether the parsed mode is -// honored — but the mechanism differs: GetConfig returns an error where seidb.go -// panics. Both parsers must agree on the resolved mode for a node's store choice -// and its reported config to describe the same thing, so the agreement is asserted -// against the shared helpers rather than restated. -func FuzzGetConfigWriteMode(f *testing.F) { - f.Add("memiavl_only", true, true) - f.Add("memiavl_only", true, false) - f.Add("cosmos_only", false, false) - f.Add("", false, false) - f.Add("", true, true) - f.Add("bogus", false, false) - f.Add("bogus", true, true) - f.Add("flatkv_only", false, false) - - f.Fuzz(func(t *testing.T, mode string, setAuto, auto bool) { - keys := map[string]any{"state-commit.sc-write-mode": mode} - if setAuto { - keys["state-commit.sc-write-mode-enable-auto"] = auto - } - - cfg, err := GetConfig(newAppViper(t, keys)) - - _, parseErr := config.ParseSCWriteMode(mode) - if mode != "" && parseErr != nil { - if err == nil { - t.Fatalf("sc-write-mode = %q does not parse and must be an error, not a panic or a fallback", mode) - } - if !strings.Contains(err.Error(), "sc-write-mode") { - t.Fatalf("the failure must name the key, got %v", err) - } - return - } - if err != nil { - t.Fatalf("sc-write-mode = %q must parse, got %v", mode, err) - } - - effectiveAuto := true - if setAuto { - effectiveAuto = auto - } - want := config.DefaultStateCommitConfig().WriteMode - if mode != "" { - parsed, perr := config.ParseSCWriteMode(mode) - if perr != nil { - t.Fatalf("mode %q was expected to parse: %v", mode, perr) - } - want = parsed - } - want = config.ApplyWriteModeAuto(effectiveAuto, want) - - if cfg.StateCommit.WriteMode != want { - t.Fatalf("sc-write-mode = %q with auto=%v resolved to %v, want %v", - mode, effectiveAuto, cfg.StateCommit.WriteMode, want) - } - if effectiveAuto && cfg.StateCommit.WriteMode != sctypes.Auto { - t.Fatalf("with auto on, the effective mode must be auto, got %v", cfg.StateCommit.WriteMode) - } - }) -} - -// guardedKey is a key GetConfig reads only when viper reports it set. -type guardedKey struct { - Key string - Path string - // Set is a value distinguishable from the default, used to prove the guard - // admits an explicit value as well as protecting an absent one. - Set any - // DefaultIsZero marks a key whose in-code default is already the zero value. - // The guard still matters there — it is what lets an operator set a non-zero - // value — but an absent key resolving to zero is correct rather than a clobber, - // so the two cases need different assertions. - DefaultIsZero bool -} - -// guardedKeys are the GetConfig reads wrapped in viper.IsSet. They are the same -// zero-clobber class app/seidb.go guards, expressed through a different mechanism. -var guardedKeys = []guardedKey{ - {Key: "state-commit.sc-async-commit-buffer", Path: "StateCommit.MemIAVLConfig.AsyncCommitBuffer", Set: 7}, - {Key: "state-commit.sc-keep-recent", Path: "StateCommit.MemIAVLConfig.SnapshotKeepRecent", Set: 9}, - {Key: "state-commit.sc-snapshot-interval", Path: "StateCommit.MemIAVLConfig.SnapshotInterval", Set: 4321}, - {Key: "state-commit.sc-snapshot-min-time-interval", Path: "StateCommit.MemIAVLConfig.SnapshotMinTimeInterval", Set: 11}, - {Key: "state-commit.sc-snapshot-writer-limit", Path: "StateCommit.MemIAVLConfig.SnapshotWriterLimit", Set: 3}, - {Key: "state-commit.sc-snapshot-prefetch-threshold", Path: "StateCommit.MemIAVLConfig.SnapshotPrefetchThreshold", Set: 0.25}, - {Key: "state-commit.flatkv.fsync", Path: "StateCommit.FlatKVConfig.Fsync", Set: true, DefaultIsZero: true}, - { - Key: "state-commit.flatkv.async-write-buffer", Path: "StateCommit.FlatKVConfig.AsyncWriteBuffer", - Set: 5, DefaultIsZero: true, - }, - {Key: "state-commit.flatkv.snapshot-interval", Path: "StateCommit.FlatKVConfig.SnapshotInterval", Set: 777}, - {Key: "state-commit.flatkv.snapshot-keep-recent", Path: "StateCommit.FlatKVConfig.SnapshotKeepRecent", Set: 6}, - { - Key: "state-commit.flatkv.enable-read-write-metrics", Path: "StateCommit.FlatKVConfig.EnableReadWriteMetrics", - Set: true, DefaultIsZero: true, - }, - {Key: "grpc.max-recv-msg-size", Path: "GRPC.MaxRecvMsgSize", Set: 8 * 1024 * 1024}, - {Key: "grpc.max-open-connections", Path: "GRPC.MaxOpenConnections", Set: 123}, - {Key: "grpc-web.max-open-connections", Path: "GRPCWeb.MaxOpenConnections", Set: 456}, -} - -// FuzzGetConfigGuardedKeysPreserveDefaults pins the guarded half of GetConfig: an -// absent key resolves to the in-code default rather than to the zero value viper's -// typed getters would otherwise return. -// -// The keys that matter most are the bounded ones. grpc.max-recv-msg-size, -// grpc.max-open-connections and grpc-web.max-open-connections all default to a -// finite limit, and an unguarded read of an absent key would resolve 0 — which -// gRPC reads as unlimited. A node upgrading with an older app.toml would go from -// bounded to unbounded connections and message sizes with nothing said about it. -func FuzzGetConfigGuardedKeysPreserveDefaults(f *testing.F) { - for i := range len(guardedKeys) { - f.Add(uint(i), false) - f.Add(uint(i), true) - } - - f.Fuzz(func(t *testing.T, keyIdx uint, present bool) { - row := guardedKeys[keyIdx%uint(len(guardedKeys))] - - absent, err := GetConfig(newAppViper(t, nil)) - if err != nil { - t.Fatalf("parsing with no optional keys must succeed, got %v", err) - } - absentLeaf, ok := configtest.LeafAt(configtest.Dump(absent), row.Path) - if !ok { - t.Fatalf("%s resolves into %q, which is not in the parsed config", row.Key, row.Path) - } - - if !present { - // Whether the guard is doing anything is decided by comparing an absent key - // against an explicit zero, not against a synthesized zero literal. A - // synthesized one has to guess the field's Go type, and guessing wrong makes - // the comparison unsatisfiable and the assertion vacuous — which is exactly - // what an int-typed literal did for every uint, uint32 and float64 row here, - // including both gRPC connection bounds. Reading the reader twice needs no - // type knowledge at all. - explicitZero, zeroErr := GetConfig(newAppViper(t, map[string]any{row.Key: 0})) - if zeroErr != nil { - t.Fatalf("%s = 0 must parse, got %v", row.Key, zeroErr) - } - zeroLeaf, ok := configtest.LeafAt(configtest.Dump(explicitZero), row.Path) - if !ok { - t.Fatalf("%s resolves into %q, which is not in the parsed config", row.Key, row.Path) - } - - if row.DefaultIsZero { - if absentLeaf != zeroLeaf { - t.Fatalf("%s is marked DefaultIsZero but an absent key (%s) resolves differently "+ - "from an explicit 0 (%s)", row.Key, absentLeaf, zeroLeaf) - } - return - } - if absentLeaf == zeroLeaf { - t.Fatalf("%s is absent and resolved to the same value as an explicit 0 (%s); the "+ - "guard that preserves the in-code default is gone", row.Key, absentLeaf) - } - return - } - - set, err := GetConfig(newAppViper(t, map[string]any{row.Key: row.Set})) - if err != nil { - t.Fatalf("%s = %#v must parse, got %v", row.Key, row.Set, err) - } - setLeaf, ok := configtest.LeafAt(configtest.Dump(set), row.Path) - if !ok { - t.Fatalf("%s resolves into %q, which is not in the parsed config", row.Key, row.Path) - } - if setLeaf == absentLeaf { - t.Fatalf("%s = %#v did not change the resolved value (%s); the guard admits an "+ - "explicit value as well as protecting an absent one", row.Key, row.Set, setLeaf) - } - }) -} - -// TestGetConfigGuardedKeyDefaultsMatchTheManifest keeps the DefaultIsZero column -// honest in both directions. -// -// It is what stops the guard assertions above from going vacuous. A key marked -// non-zero whose default moves to zero would make its clobber check meaningless; -// a key marked zero whose default becomes non-zero would leave a real clobber -// unchecked. Either way the manifest, not the assertion, is what needs updating. -// -// "Is the default zero" is answered by resolving the key explicitly as 0 and -// comparing, for the same reason the target above does it that way: a literal 0 -// carries Go's int type and would never compare equal to a uint or float64 leaf. -func TestGetConfigGuardedKeyDefaultsMatchTheManifest(t *testing.T) { - absent, err := GetConfig(newAppViper(t, nil)) - if err != nil { - t.Fatalf("GetConfig: %v", err) - } - absentDump := configtest.Dump(absent) - - for _, row := range guardedKeys { - absentLeaf, ok := configtest.LeafAt(absentDump, row.Path) - if !ok { - t.Errorf("%s: %q is not in the parsed config", row.Key, row.Path) - continue - } - explicitZero, zeroErr := GetConfig(newAppViper(t, map[string]any{row.Key: 0})) - if zeroErr != nil { - t.Errorf("%s = 0 must parse, got %v", row.Key, zeroErr) - continue - } - zeroLeaf, ok := configtest.LeafAt(configtest.Dump(explicitZero), row.Path) - if !ok { - t.Errorf("%s: %q is not in the parsed config", row.Key, row.Path) - continue - } - - if isZero := absentLeaf == zeroLeaf; isZero != row.DefaultIsZero { - t.Errorf("%s resolves to %s with no key set and %s with an explicit 0, so "+ - "DefaultIsZero is %v while the manifest says %v; update the row so its guard "+ - "assertion still means something", - row.Key, absentLeaf, zeroLeaf, isZero, row.DefaultIsZero) - } - } -} - -// TestGetConfigGenesisKeyDivergesFromTheAppSideKey records that the two genesis -// parsers read different keys for the same value. GetConfig reads -// genesis.genesis-stream-file; app/genesis.go reads genesis.import-file. Setting -// one leaves the other empty, so a stream-import node configured through the key -// the template renders streams from "". -func TestGetConfigGenesisKeyDivergesFromTheAppSideKey(t *testing.T) { - cfg, err := GetConfig(newAppViper(t, map[string]any{ - "genesis.stream-import": true, - "genesis.import-file": "/var/lib/sei/genesis.json", - })) - if err != nil { - t.Fatalf("GetConfig: %v", err) - } - if !cfg.Genesis.StreamImport { - t.Fatal("genesis.stream-import must resolve; both parsers agree on this key") - } - if cfg.Genesis.GenesisStreamFile != "" { - t.Fatalf("GetConfig read genesis.import-file (%q); it reads genesis-stream-file, and the "+ - "divergence between the two spellings is the pinned behavior", - cfg.Genesis.GenesisStreamFile) - } - - withOwnKey, err := GetConfig(newAppViper(t, map[string]any{ - "genesis.genesis-stream-file": "/var/lib/sei/genesis.json", - })) - if err != nil { - t.Fatalf("GetConfig: %v", err) - } - if withOwnKey.Genesis.GenesisStreamFile != "/var/lib/sei/genesis.json" { - t.Fatalf("genesis.genesis-stream-file resolved to %q", withOwnKey.Genesis.GenesisStreamFile) - } -} - -// TestGetConfigStateStoreReadsAreUnguarded records that GetConfig's [state-store] -// parse has no presence checks, matching app/seidb.go's parseSSConfigs. An absent -// section resolves every field to its zero value, so the reported config says -// ss-enable false and an empty backend on a node whose app.toml simply predates the -// section. -func TestGetConfigStateStoreReadsAreUnguarded(t *testing.T) { - cfg, err := GetConfig(newAppViper(t, nil)) - if err != nil { - t.Fatalf("GetConfig: %v", err) - } - def := config.DefaultStateStoreConfig() - if cfg.StateStore.Enable == def.Enable && def.Enable { - t.Fatal("state-store.ss-enable is no longer clobbered by an absent key; if a guard was " + - "added, GetConfig and parseSSConfigs must be changed together or they will disagree") - } - if cfg.StateStore.Backend == def.Backend && def.Backend != "" { - t.Fatalf("state-store.ss-backend resolved to the default %q from an absent key", cfg.StateStore.Backend) - } - if cfg.StateStore.AsyncWriteBuffer != 0 || cfg.StateStore.KeepRecent != 0 { - t.Fatalf("absent [state-store] must resolve to zeros, got buffer=%d keep-recent=%d", - cfg.StateStore.AsyncWriteBuffer, cfg.StateStore.KeepRecent) - } -} - -// stateSyncKeys is the [state-sync] manifest as GetConfig resolves it. -// -// The section is read three ways and this describes one of them — counted by mechanism, because -// a count of readers goes stale: simd is a fourth call site (sei-ibc-go/testing/simapp/simd/cmd/ -// root.go:273-274) and a second instance of the first mechanism, not a fourth way. NewApp reads -// the same three keys out of an AppOpts and hands them to a baseapp (cmd/seid/cmd/root.go:255 -// and :304-306), which no row can predict. ParseConfig (toml.go:272-277) unmarshals them by mapstructure tag -// over a DefaultConfig base, so an absent snapshot-keep-recent keeps 2 where this reader -// returns 0 — a second describable reader, undescribed. GetConfig reads them as literals -// through one unguarded typed getter each, which is the shape CheckRow holds a reader to. -// -// All three reads are unguarded and one of them clobbers, which is why this section gets no -// CheckAbsent: an empty viper does not resolve to DefaultConfig's [state-sync]. -// TestGetConfigStateSyncReadsAreUnguarded pins that divergence directly. -var stateSyncKeys = []configtest.KeySpec{ - { - Key: "state-sync.snapshot-interval", Path: "SnapshotInterval", - Cast: configtest.CastUint64, Unguarded: true, - Why: "0 is both the in-code default and \"disabled\", so what this row protects is an " + - "explicit interval reaching the field rather than an absent one: it is the only " + - "[state-sync] key with a consumer inside this Config (ValidateBasic, config.go:655), " + - "and a serving node's cadence is NewApp's read (root.go:304)", - }, - { - Key: "state-sync.snapshot-keep-recent", Path: "SnapshotKeepRecent", - Cast: configtest.CastUint32, Unguarded: true, - Why: "the in-code default is 2 and toml.go:78 documents 0 as keep all, so the clobber " + - "inverts the declared retention; what a node retains is NewApp's read (root.go:305)", - }, - { - Key: "state-sync.snapshot-directory", Path: "SnapshotDirectory", - Cast: configtest.CastString, Unguarded: true, - Why: "toml.go:82 documents empty as store under the home directory, and the fallback that " + - "implements it is NewApp's read (root.go:255-257) rather than this one", - }, -} - -// readStateSync drives GetConfig from an AppOpts, which is what lets the manifest engine -// describe a viper-based reader. -// -// The two transports differ by an adapter and not by a wall: newAppViper already takes the -// map[string]any an AppOpts is, so a row's key and raw value reach v.Set and then the same -// typed getter an app.toml value reaches. It returns the section rather than the whole -// Config so a row's Path names its field the way every other section's rows do, which is -// also what lets CheckManifestCoversEveryField point at StateSyncConfig alone. -func readStateSync(t testing.TB) func(configtest.AppOpts) (any, error) { - return func(opts configtest.AppOpts) (any, error) { - cfg, err := GetConfig(newAppViper(t, opts)) - if err != nil { - return nil, err - } - return cfg.StateSync, nil - } -} - -// FuzzGetConfigStateSync drives the [state-sync] manifest. -// -// Two of the three keys had no assertion on a resolved value anywhere in the tree: cmd/seid/cmd -// records snapshot-keep-recent's and snapshot-directory's spelling and cannot predict what NewApp -// does with them, and the whole-Config defaults golden says what DefaultConfig declares rather -// than what a parse returns. snapshot-interval was already held twice — FuzzConfigValidateBasic -// below drives it through GetConfig with a wantErr that is a function of its resolved value, and -// appKeys[5] in cmd/seid/cmd's FuzzApplyPrecedenceApp holds it to a resolved value and Go type -// across every layer combination that sets it — so for that key this target adds arbitrary values -// rather than a first assertion. -func FuzzGetConfigStateSync(f *testing.F) { - read := readStateSync(f) - seeds := configtest.NewSeeds(f, fuzzing.ConfigValue) - - // Rows 0 and 1 get three seeds and row 2 gets two, and only the first seed of each row - // discriminates. An unguarded read resolves an absent key to its cast's zero, and every one - // of these rows resolves to that same zero from an absent key, so a nil seed lands on the - // absent-key value and so does a value the cast rejects: those pin the clobber and, on the - // two numeric rows, the swallowed conversion. Row 2 stops at two because a string cast has - // no malformed input, so there is no swallowed conversion to pin there. A value that - // converts to something else is what states the key is read at all. - seeds.AddRow(uint(0), fuzzing.KindInt64, "", int64(1000), false) - seeds.AddRow(uint(0), fuzzing.KindNil, "", int64(0), false) - seeds.AddRow(uint(0), fuzzing.KindString, "not-a-number", int64(0), false) - seeds.AddRow(uint(1), fuzzing.KindInt64, "", int64(10), false) - seeds.AddRow(uint(1), fuzzing.KindNil, "", int64(0), false) - seeds.AddRow(uint(1), fuzzing.KindString, "not-a-number", int64(0), false) - seeds.AddRow(uint(2), fuzzing.KindString, "/var/lib/sei/snapshots", int64(0), false) - seeds.AddRow(uint(2), fuzzing.KindNil, "", int64(0), false) - - configtest.CheckEveryRowHasADiscriminatingSeed(f, "state-sync", read, stateSyncKeys, seeds) - - f.Fuzz(func(t *testing.T, keyIdx uint, kind uint8, s string, n int64, b bool) { - spec := configtest.Pick(stateSyncKeys, keyIdx) - configtest.CheckRow(t, "state-sync", readStateSync(t), spec, fuzzing.ConfigValue(kind, s, n, b)) - }) -} - -// TestGetConfigStateSyncReadsAreUnguarded records the [state-sync] clobber as a divergence -// rather than as two facts that happen to disagree. -// -// snapshot-keep-recent is a registered flag defaulting to 2 (server/start.go:234) and -// DefaultConfig declares 2, but GetConfig reads it with a bare v.GetUint32, so a viper that -// never saw the key resolves 0 — which toml.go:78 documents as "keep all". That viper is this -// file's layer and not a booted node's: start.go:117 binds the flag in PreRunE, ahead of both -// production calls (start.go:168 and :303), so there the same read takes the flag's 2 whenever -// app.toml is silent. -// -// It is recorded and not repaired, because a characterization PR does not change readers. The -// guard is not hypothetical: ParseConfig already resolves this section that way and returns 2 for -// the absent key this read returns 0 for. The guard would not change the fleet either: with the -// flag bound IsSet is false, so a guarded read falls back to the in-code 2 the unguarded read -// already takes from the flag default. The point of the assertion is that either side moving fails -// it — a guard makes the absent read return 2, and a default moved to 0 makes the clobber stop -// being one — so the divergence can neither close nor widen without a diff. -func TestGetConfigStateSyncReadsAreUnguarded(t *testing.T) { - cfg, err := GetConfig(newAppViper(t, nil)) - if err != nil { - t.Fatalf("GetConfig: %v", err) - } - def := DefaultConfig().StateSync - if def.SnapshotKeepRecent == 0 { - t.Fatal("state-sync.snapshot-keep-recent's in-code default is now 0, so an absent key " + - "clobbers nothing and this recording no longer describes a divergence. If the default " + - "moved deliberately, say what a serving node now retains — 0 is keep-all, not keep-none") - } - if cfg.StateSync.SnapshotKeepRecent != 0 { - t.Fatalf("an absent state-sync.snapshot-keep-recent resolved to %d rather than 0, so the "+ - "read is no longer unguarded. That is a fine end state and a no-op for a booted node, "+ - "which takes 2 from the bound flag either way (start.go:117 and :234); what it changes "+ - "is this recording, so update the row and this assertion in the PR that adds the guard", - cfg.StateSync.SnapshotKeepRecent) - } - if cfg.StateSync.SnapshotInterval != 0 || cfg.StateSync.SnapshotDirectory != "" { - t.Fatalf("absent [state-sync] must resolve to zeros, got interval=%d directory=%q", - cfg.StateSync.SnapshotInterval, cfg.StateSync.SnapshotDirectory) - } -} - -// TestParseConfigAndGetConfigDisagree pins the disagreement between this package's two exported -// readers of [state-sync], which is what the guard above would close. -// -// Handed the same flagless viper, ParseConfig unmarshals over a DefaultConfig base and keeps -// snapshot-keep-recent at 2 while GetConfig's bare v.GetUint32 resolves 0. That is what makes the -// deferral above a deferral rather than an open question: the guard is not a retention anyone has -// to choose, it is ParseConfig's resolution moved into GetConfig, and this states the behavior -// being moved. Nothing else in the tree does — TestParseConfig (config_test.go:358) asserts only -// MinGasPrices. -// -// The disagreement is between the two readers and not between two nodes. GetConfig's production -// calls (start.go:168 and :303) run with the flag bound, where it takes the same 2, and the one -// non-test caller of ParseConfig is server/util.go:308's empty-template branch, which no binary in -// this tree reaches. So what fails when either side moves is the rationale, which is the point. -func TestParseConfigAndGetConfigDisagree(t *testing.T) { - v := newAppViper(t, nil) - parsed, err := ParseConfig(v) - if err != nil { - t.Fatalf("ParseConfig: %v", err) - } - direct, err := GetConfig(v) - if err != nil { - t.Fatalf("GetConfig: %v", err) - } - if parsed.StateSync.SnapshotKeepRecent != 2 || direct.StateSync.SnapshotKeepRecent != 0 { - t.Fatalf("an absent state-sync.snapshot-keep-recent resolved to %d through ParseConfig and "+ - "%d through GetConfig, want 2 and 0. If GetConfig gained the guard the two readers now "+ - "agree, which is the end state — update this test, the row and the deferral note "+ - "together. If ParseConfig stopped resolving the section over DefaultConfig, the deferral "+ - "note above is now wrong about there being an implemented guard to copy, and whoever "+ - "adds one is choosing a retention rather than matching one. A third cause is that the "+ - "in-code default moved off 2, which the defaults golden names and this literal does "+ - "not: a default of 3 reaches here rather than the unguarded-read check, whose guard "+ - "fires only at exactly 0", - parsed.StateSync.SnapshotKeepRecent, direct.StateSync.SnapshotKeepRecent) - } -} - -// TestKeyNamesMatchTheRecordedNames records the [state-sync] spellings GetConfig looks up. -// -// The rows name them as literals, so a rename here fails the row assertions as well; the -// record is what makes the diff name the old and the new operator-facing key rather than a -// resolved value. cmd/seid/cmd holds its own state-sync record for NewApp's two constants, -// and the two files are independent because the readers they describe are. -func TestKeyNamesMatchTheRecordedNames(t *testing.T) { - configtest.CheckKeyNames(t, "state-sync", stateSyncKeys) -} - -// TestManifestNamesEveryField enforces the claim stateSyncKeys makes about itself. -// -// StateSyncConfig is the one struct in this file's surface with a single reader populating -// it, so the check costs no exemptions: a fourth [state-sync] key added to GetConfig fails -// here until it has a row. The [state-commit] and [state-store] structs are shared with -// app/seidb.go and are not assertable this way — see app/config_fuzz_test.go. -func TestManifestNamesEveryField(t *testing.T) { - configtest.CheckManifestCoversEveryField(t, "state-sync", DefaultConfig().StateSync, stateSyncKeys) -} - -// FuzzConfigValidateBasic pins the two conditions that reject an otherwise -// parseable app.toml. -// -// An empty minimum-gas-prices fails, because a validator accepting zero-fee -// transactions is a misconfiguration rather than a choice. And pruning -// "everything" with state-sync snapshots enabled fails, because a node cannot -// serve a snapshot of state it has already pruned. Both are the rare case in this -// surface where a bad combination is refused rather than absorbed. -func FuzzConfigValidateBasic(f *testing.F) { - f.Add("0.01usei", "default", uint64(0)) - f.Add("", "default", uint64(0)) - f.Add("0.01usei", "everything", uint64(100)) - f.Add("0.01usei", "everything", uint64(0)) - f.Add("", "everything", uint64(100)) - f.Add("0.01usei", "nothing", uint64(100)) - - f.Fuzz(func(t *testing.T, minGasPrices, pruning string, snapshotInterval uint64) { - cfg, err := GetConfig(newAppViper(t, map[string]any{ - "minimum-gas-prices": minGasPrices, - "pruning": pruning, - "state-sync.snapshot-interval": snapshotInterval, - })) - if err != nil { - t.Fatalf("GetConfig: %v", err) - } - - wantErr := minGasPrices == "" || (pruning == "everything" && snapshotInterval > 0) - got := cfg.ValidateBasic(nil) - if wantErr && got == nil { - t.Fatalf("min-gas-prices=%q pruning=%q snapshot-interval=%d must fail ValidateBasic", - minGasPrices, pruning, snapshotInterval) - } - if !wantErr && got != nil { - t.Fatalf("min-gas-prices=%q pruning=%q snapshot-interval=%d must pass ValidateBasic, got %v", - minGasPrices, pruning, snapshotInterval, got) - } - }) -} - -// TestDefaultsMatchTheRecordedValues pins the server_config defaults themselves. -// -// The absent-keys coverage in this file proves the reader returns the declared defaults; it -// cannot prove which values those are, because both sides of that comparison come from the -// same package. This compares them against testdata/server_config.golden, an independent -// recording, so a default that moves shows the new value in a diff instead of passing -// silently. -func TestDefaultsMatchTheRecordedValues(t *testing.T) { - // [state-sync] has its own manifest and its own struct, so it gets its own record. The three - // values are also inside server_config.golden, which does catch a change to them, so this is for - // discoverability rather than detection. A reader asking what [state-sync] defaults to reads three - // lines here instead of finding them among two hundred, and the section shows its own defaults - // check in the coverage record. Regenerating one of the two records without the other leaves that - // other one red. - configtest.CheckDefaults(t, "state-sync", DefaultConfig().StateSync) - - configtest.CheckDefaults(t, "server_config", DefaultConfig(), - configtest.DerivedDefault{ - Path: "ConcurrencyWorkers", Want: max(10, min(runtime.NumCPU()*2, 128)), - Why: "max(10, min(runtime.NumCPU()*2, 128))", - }, - ) -} - -// apiKeys covers the [api] keys GetConfig reads. -// -// Every read is a bare viper getter with no IsSet guard (config.go:579-586), so an absent key -// resolves to that getter's zero rather than to what DefaultConfig declares. Unlike [state-sync], -// no api.* flag is registered anywhere in this tree, so nothing supplies a fallback and the zero -// is what a node gets. TestGetConfigAbsentSectionDivergences records which fields that changes. -var apiKeys = []configtest.KeySpec{ - { - Key: "api.enable", Path: "Enable", Cast: configtest.CastBool, Unguarded: true, - Why: "whether the node serves the REST API at all; false is also the declared default, so " + - "this row states the key is read rather than recording a divergence", - }, - { - Key: "api.swagger", Path: "Swagger", Cast: configtest.CastBool, Unguarded: true, - Why: "the declared default is true and an absent key resolves false, so a node whose " + - "app.toml lacks the section serves the API without its documentation", - }, - { - Key: "api.enabled-unsafe-cors", Path: "EnableUnsafeCORS", Cast: configtest.CastBool, - Unguarded: true, - Why: "cross-origin access to the REST API; false either way, so the clobber cannot turn " + - "this on, which is the direction that would matter", - }, - { - Key: "api.address", Path: "Address", Cast: configtest.CastString, Unguarded: true, - Why: "the declared default is tcp://0.0.0.0:1317 and an absent key resolves empty, so the " + - "listener address a node binds comes from the file or from nowhere", - }, - { - Key: "api.max-open-connections", Path: "MaxOpenConnections", Cast: configtest.CastUint, - Unguarded: true, - Why: "the declared default is 1000 and an absent key resolves 0, so the connection ceiling " + - "a node enforces is whichever of those the server treats as a limit", - }, - { - Key: "api.rpc-read-timeout", Path: "RPCReadTimeout", Cast: configtest.CastUint, - Unguarded: true, - Why: "the declared default is 10 seconds and an absent key resolves 0, so a node whose " + - "app.toml lacks the section reads a request body with no deadline", - }, - { - Key: "api.rpc-write-timeout", Path: "RPCWriteTimeout", Cast: configtest.CastUint, - Unguarded: true, - Why: "0 is both the declared default and what an absent key resolves to, so this row " + - "states the key is read rather than recording a divergence", - }, - { - Key: "api.rpc-max-body-bytes", Path: "RPCMaxBodyBytes", Cast: configtest.CastUint, - Unguarded: true, - Why: "the declared default is 1000000 and an absent key resolves 0, so the response body " + - "ceiling a node applies is whichever of those the server treats as a limit", - }, -} - -func readAPI(t testing.TB) func(configtest.AppOpts) (any, error) { - return sectionOfGetConfig(t, func(c Config) any { return c.API }) -} - -// FuzzAPIConfig drives every [api] row. -// -// Three seeds per row, uniformly. Every row here is unguarded, so an absent key, a nil value and a -// value the cast rejects all land on the same zero, and only a value that converts to something else -// states the key is read at all. The nil and malformed pair comes from seedEveryRow, the same shape -// the other five sections use. -func FuzzAPIConfig(f *testing.F) { - read := readAPI(f) - seeds := configtest.NewSeeds(f, fuzzing.ConfigValue) - seedEveryRow(seeds, len(apiKeys)) - - // The discriminating value per row, each chosen away from the value an absent key resolves to, which - // is what CheckEveryRowHasADiscriminatingSeed holds them to. For a bool whose declared default is - // not the zero that is the default itself, since a bool has only the two. - seeds.AddRow(uint(0), fuzzing.KindBool, "", int64(0), true) // enable - seeds.AddRow(uint(1), fuzzing.KindBool, "", int64(0), true) // swagger - seeds.AddRow(uint(2), fuzzing.KindBool, "", int64(0), true) // unsafe CORS - seeds.AddRow(uint(3), fuzzing.KindString, "tcp://127.0.0.1:11317", int64(0), false) - seeds.AddRow(uint(4), fuzzing.KindInt64, "", int64(250), false) // max-open-connections - seeds.AddRow(uint(5), fuzzing.KindInt64, "", int64(30), false) // rpc-read-timeout - seeds.AddRow(uint(6), fuzzing.KindInt64, "", int64(45), false) // rpc-write-timeout - seeds.AddRow(uint(7), fuzzing.KindInt64, "", int64(2000000), false) // rpc-max-body-bytes - - configtest.CheckEveryRowHasADiscriminatingSeed(f, "api", read, apiKeys, seeds) - - f.Fuzz(func(t *testing.T, keyIdx uint, kind uint8, s string, n int64, b bool) { - spec := configtest.Pick(apiKeys, keyIdx) - configtest.CheckRow(t, "api", readAPI(t), spec, fuzzing.ConfigValue(kind, s, n, b)) - }) -} - -// TestAPIKeyNamesMatchTheRecordedNames pins the operator-facing spelling of the eight [api] keys. -func TestAPIKeyNamesMatchTheRecordedNames(t *testing.T) { - configtest.CheckKeyNames(t, "api", apiKeys) -} - -// TestAPIManifestNamesEveryField enforces the claim apiKeys makes about itself, that it names every -// key the reader looks up. -func TestAPIManifestNamesEveryField(t *testing.T) { - configtest.CheckManifestCoversEveryField(t, "api", DefaultConfig().API, apiKeys) -} - -// rosetta covers the [rosetta] keys GetConfig reads. Every read is a bare viper getter -// (config.go:589-594), so an absent key resolves to that getter's zero. -var rosettaKeys = []configtest.KeySpec{ - { - Key: "rosetta.enable", Path: "Enable", Cast: configtest.CastBool, Unguarded: true, - Why: "whether the node serves the Rosetta API; false either way, so this row states the " + - "key is read rather than recording a divergence", - }, - { - Key: "rosetta.address", Path: "Address", Cast: configtest.CastString, Unguarded: true, - Why: "the declared default is :8080 and an absent key resolves empty, so the listener " + - "address comes from the file or from nowhere", - }, - { - Key: "rosetta.blockchain", Path: "Blockchain", Cast: configtest.CastString, Unguarded: true, - Why: "the declared default is app and an absent key resolves empty, so the blockchain name " + - "Rosetta reports identifies nothing", - }, - { - Key: "rosetta.network", Path: "Network", Cast: configtest.CastString, Unguarded: true, - Why: "the declared default is network and an absent key resolves empty, so the network name " + - "Rosetta reports identifies nothing", - }, - { - Key: "rosetta.retries", Path: "Retries", Cast: configtest.CastInt, Unguarded: true, - Why: "the declared default is 3 and an absent key resolves 0, so a node retries a failed " + - "Rosetta operation as many times as its file says and no more", - }, - { - Key: "rosetta.offline", Path: "Offline", Cast: configtest.CastBool, Unguarded: true, - Why: "whether Rosetta runs without a live node; false either way, so this row states the " + - "key is read rather than recording a divergence", - }, -} - -// grpcWebKeys covers the [grpc-web] keys GetConfig reads. -// -// Three of the four are bare getters. max-open-connections is not: config.go:514-517 reads it -// behind v.IsSet and falls back to the in-code default, with a comment saying the guard is there so -// a node upgrading with an older app.toml stays bounded. That is the same hazard -// api.max-open-connections and api.rpc-max-body-bytes carry unguarded, which is why this section is -// worth reading beside apiKeys rather than on its own. -var grpcWebKeys = []configtest.KeySpec{ - { - Key: "grpc-web.enable", Path: "Enable", Cast: configtest.CastBool, Unguarded: true, - Why: "the declared default is true and an absent key resolves false, so a node whose " + - "app.toml lacks the section serves no gRPC-Web", - }, - { - Key: "grpc-web.address", Path: "Address", Cast: configtest.CastString, Unguarded: true, - Why: "the declared default is 0.0.0.0:9091 and an absent key resolves empty", - }, - { - Key: "grpc-web.enable-unsafe-cors", Path: "EnableUnsafeCORS", Cast: configtest.CastBool, - Unguarded: true, - Why: "cross-origin access to gRPC-Web; false either way, so the clobber cannot turn this " + - "on, which is the direction that would matter", - }, - { - Key: "grpc-web.max-open-connections", Path: "MaxOpenConnections", Cast: configtest.CastUint, - Why: "the one guarded read in this section (config.go:514-517), so an absent key keeps the " + - "declared 1000 rather than resolving 0; the guard exists so an upgrading node stays bounded", - }, -} - -// telemetryKeys covers the [telemetry] keys GetConfig reads as scalars. -// -// global-labels is not a row. It is read as a bare type assertion whose absence fails GetConfig -// outright and whose shape rules are their own subject, so it has dedicated targets above -// (FuzzGetConfigGlobalLabels, TestGetConfigRequiresGlobalLabels, TestGetConfigPanicsOnNonStringLabel) -// and is recorded by name rather than driven as a row. -var telemetryKeys = []configtest.KeySpec{ - { - Key: "telemetry.service-name", Path: "ServiceName", Cast: configtest.CastString, - Unguarded: true, - Why: "empty either way, so this row states the key is read rather than recording a divergence", - }, - { - Key: "telemetry.enabled", Path: "Enabled", Cast: configtest.CastBool, Unguarded: true, - Why: "the declared default is true and an absent key resolves false, so a node whose " + - "app.toml lacks the section emits no telemetry", - }, - { - Key: "telemetry.enable-hostname", Path: "EnableHostname", Cast: configtest.CastBool, - Unguarded: true, - Why: "false either way, so this row states the key is read", - }, - { - Key: "telemetry.enable-hostname-label", Path: "EnableHostnameLabel", - Cast: configtest.CastBool, Unguarded: true, - Why: "false either way, so this row states the key is read", - }, - { - Key: "telemetry.enable-service-label", Path: "EnableServiceLabel", Cast: configtest.CastBool, - Unguarded: true, - Why: "false either way, so this row states the key is read", - }, - { - Key: "telemetry.prometheus-retention-time", Path: "PrometheusRetentionTime", - Cast: configtest.CastInt64, Unguarded: true, - Why: "the declared default is 7200 seconds and an absent key resolves 0, which telemetry " + - "reads as retaining nothing, so a scrape finds an empty store", - }, -} - -// telemetryKeysWithTargetsOfTheirOwn is global-labels, recorded for its name because its behaviour -// is driven by targets rather than by a row. -var telemetryKeysWithTargetsOfTheirOwn = []configtest.KeyName{"telemetry.global-labels"} - -func readRosetta(t testing.TB) func(configtest.AppOpts) (any, error) { - return sectionOfGetConfig(t, func(c Config) any { return c.Rosetta }) -} - -func readGRPCWeb(t testing.TB) func(configtest.AppOpts) (any, error) { - return sectionOfGetConfig(t, func(c Config) any { return c.GRPCWeb }) -} - -func readTelemetry(t testing.TB) func(configtest.AppOpts) (any, error) { - return sectionOfGetConfig(t, func(c Config) any { return c.Telemetry }) -} - -// sectionOfGetConfig adapts GetConfig to the reader shape the checks take, for one section. -// -// One helper rather than a function per section, because every one of these differs only in which -// field it returns, and a per-section copy is a place for the newAppViper call to drift. -func sectionOfGetConfig(t testing.TB, section func(Config) any) func(configtest.AppOpts) (any, error) { - return func(opts configtest.AppOpts) (any, error) { - cfg, err := GetConfig(newAppViper(t, opts)) - if err != nil { - return nil, err - } - return section(cfg), nil - } -} - -// seedEveryRow gives each row a nil and a malformed seed, which is the pair every unguarded section -// needs so an ordinary go test run reaches the clobber and the swallowed conversion. -func seedEveryRow(seeds *configtest.Seeds, rows int) { - for i := range rows { - seeds.AddRow(uint(i), fuzzing.KindNil, "", int64(0), false) - seeds.AddRow(uint(i), fuzzing.KindString, "not-a-value", int64(0), false) - } -} - -func FuzzRosettaConfig(f *testing.F) { - seeds := configtest.NewSeeds(f, fuzzing.ConfigValue) - seedEveryRow(seeds, len(rosettaKeys)) - - // One discriminating value per row, away from the value an absent key resolves to. - seeds.AddRow(uint(0), fuzzing.KindBool, "", int64(0), true) - seeds.AddRow(uint(1), fuzzing.KindString, ":18080", int64(0), false) - seeds.AddRow(uint(2), fuzzing.KindString, "sei-app", int64(0), false) - seeds.AddRow(uint(3), fuzzing.KindString, "sei-network", int64(0), false) - seeds.AddRow(uint(4), fuzzing.KindInt64, "", int64(9), false) - seeds.AddRow(uint(5), fuzzing.KindBool, "", int64(0), true) - - configtest.CheckEveryRowHasADiscriminatingSeed(f, "rosetta", readRosetta(f), rosettaKeys, seeds) - - f.Fuzz(func(t *testing.T, keyIdx uint, kind uint8, s string, n int64, b bool) { - spec := configtest.Pick(rosettaKeys, keyIdx) - configtest.CheckRow(t, "rosetta", readRosetta(t), spec, fuzzing.ConfigValue(kind, s, n, b)) - }) -} - -func FuzzGRPCWebConfig(f *testing.F) { - seeds := configtest.NewSeeds(f, fuzzing.ConfigValue) - seedEveryRow(seeds, len(grpcWebKeys)) - - seeds.AddRow(uint(0), fuzzing.KindBool, "", int64(0), true) - seeds.AddRow(uint(1), fuzzing.KindString, "127.0.0.1:19091", int64(0), false) - seeds.AddRow(uint(2), fuzzing.KindBool, "", int64(0), true) - seeds.AddRow(uint(3), fuzzing.KindInt64, "", int64(250), false) - - configtest.CheckEveryRowHasADiscriminatingSeed(f, "grpc-web", readGRPCWeb(f), grpcWebKeys, seeds) - - f.Fuzz(func(t *testing.T, keyIdx uint, kind uint8, s string, n int64, b bool) { - spec := configtest.Pick(grpcWebKeys, keyIdx) - configtest.CheckRow(t, "grpc-web", readGRPCWeb(t), spec, fuzzing.ConfigValue(kind, s, n, b)) - }) -} - -func FuzzTelemetryConfig(f *testing.F) { - seeds := configtest.NewSeeds(f, fuzzing.ConfigValue) - seedEveryRow(seeds, len(telemetryKeys)) - - seeds.AddRow(uint(0), fuzzing.KindString, "sei-node", int64(0), false) - seeds.AddRow(uint(1), fuzzing.KindBool, "", int64(0), true) - seeds.AddRow(uint(2), fuzzing.KindBool, "", int64(0), true) - seeds.AddRow(uint(3), fuzzing.KindBool, "", int64(0), true) - seeds.AddRow(uint(4), fuzzing.KindBool, "", int64(0), true) - seeds.AddRow(uint(5), fuzzing.KindInt64, "", int64(3600), false) - - configtest.CheckEveryRowHasADiscriminatingSeed(f, "telemetry", readTelemetry(f), telemetryKeys, - seeds, telemetryKeysWithTargetsOfTheirOwn...) - - f.Fuzz(func(t *testing.T, keyIdx uint, kind uint8, s string, n int64, b bool) { - spec := configtest.Pick(telemetryKeys, keyIdx) - configtest.CheckRow(t, "telemetry", readTelemetry(t), spec, fuzzing.ConfigValue(kind, s, n, b)) - }) -} - -func TestRosettaKeyNamesMatchTheRecordedNames(t *testing.T) { - configtest.CheckKeyNames(t, "rosetta", rosettaKeys) -} - -func TestGRPCWebKeyNamesMatchTheRecordedNames(t *testing.T) { - configtest.CheckKeyNames(t, "grpc-web", grpcWebKeys) -} - -func TestTelemetryKeyNamesMatchTheRecordedNames(t *testing.T) { - configtest.CheckKeyNames(t, "telemetry", telemetryKeys, telemetryKeysWithTargetsOfTheirOwn...) -} - -func TestRosettaManifestNamesEveryField(t *testing.T) { - configtest.CheckManifestCoversEveryField(t, "rosetta", DefaultConfig().Rosetta, rosettaKeys) -} - -func TestGRPCWebManifestNamesEveryField(t *testing.T) { - configtest.CheckManifestCoversEveryField(t, "grpc-web", DefaultConfig().GRPCWeb, grpcWebKeys) -} - -func TestTelemetryManifestNamesEveryField(t *testing.T) { - configtest.CheckManifestCoversEveryField(t, "telemetry", DefaultConfig().Telemetry, telemetryKeys, - // FuzzGetConfigGlobalLabels drives this field; it is not a plain guarded cast. - "GlobalLabels", - ) -} - -// TestGetConfigAbsentSectionDivergences records every field these sections resolve away from its -// declared default when the section is absent from app.toml. -// -// One table for all of them, because the divergence is one property and a reader comparing sections -// wants them side by side. It puts api.max-open-connections and api.rpc-max-body-bytes beside the -// guarded grpc-web.max-open-connections, which is the contrast worth seeing. -// -// The diverges column asserts both directions, so a key is anchored whether or not it moves today. A -// declared default later shifting onto the getter's zero, or off it, fails here rather than changing -// the divergence set quietly. The rows set false are the keys whose declared default already equals -// that zero. -// -// Every plain cast across the six sections is here. None of these sections is wired to CheckAbsent, -// so this table is the only thing tying their absent-key resolution to their declared defaults. -// -// Compared with reflect.DeepEqual rather than !=, because != on two any values panics rather than -// reporting when either side holds a slice or a map. index-events already holds a []string, and a -// field type changing to one later would otherwise turn this table into a panic. -func TestGetConfigAbsentSectionDivergences(t *testing.T) { - cfg, err := GetConfig(newAppViper(t, nil)) - if err != nil { - t.Fatalf("GetConfig: %v", err) - } - def := DefaultConfig() - - covered := map[string]bool{} - - for _, c := range []struct { - key string - absent, declared any - diverges bool - }{ - {"rosetta.address", cfg.Rosetta.Address, def.Rosetta.Address, true}, - {"rosetta.blockchain", cfg.Rosetta.Blockchain, def.Rosetta.Blockchain, true}, - {"rosetta.network", cfg.Rosetta.Network, def.Rosetta.Network, true}, - {"rosetta.retries", cfg.Rosetta.Retries, def.Rosetta.Retries, true}, - {"grpc-web.enable", cfg.GRPCWeb.Enable, def.GRPCWeb.Enable, true}, - {"grpc-web.address", cfg.GRPCWeb.Address, def.GRPCWeb.Address, true}, - // The [grpc] keys read as plain casts. keepalive-permit-without-stream is the third, further - // down with the other rows whose default is already the zero. Its remaining eight are guarded - // or clamped and held by TestGetConfigGRPCAbsentReads. - {"grpc.enable", cfg.GRPC.Enable, def.GRPC.Enable, true}, - {"grpc.address", cfg.GRPC.Address, def.GRPC.Address, true}, - {"telemetry.enabled", cfg.Telemetry.Enabled, def.Telemetry.Enabled, true}, - { - "telemetry.prometheus-retention-time", - cfg.Telemetry.PrometheusRetentionTime, def.Telemetry.PrometheusRetentionTime, true, - }, - - // [api]. Five diverge. The three set false have a declared default that is already the - // getter's zero, so nothing about the resolved value distinguishes a guard from its absence. - {"api.swagger", cfg.API.Swagger, def.API.Swagger, true}, - {"api.address", cfg.API.Address, def.API.Address, true}, - {"api.max-open-connections", cfg.API.MaxOpenConnections, def.API.MaxOpenConnections, true}, - {"api.rpc-read-timeout", cfg.API.RPCReadTimeout, def.API.RPCReadTimeout, true}, - {"api.rpc-max-body-bytes", cfg.API.RPCMaxBodyBytes, def.API.RPCMaxBodyBytes, true}, - {"api.enable", cfg.API.Enable, def.API.Enable, false}, - {"api.enabled-unsafe-cors", cfg.API.EnableUnsafeCORS, def.API.EnableUnsafeCORS, false}, - {"api.rpc-write-timeout", cfg.API.RPCWriteTimeout, def.API.RPCWriteTimeout, false}, - - // The top-level keys, written with no section header. occ-enabled resolving false runs a node - // without optimistic concurrency control, and minimum-gas-prices resolving empty is the - // spelling for accepting a transaction at any fee. - {"minimum-gas-prices", cfg.MinGasPrices, def.MinGasPrices, true}, - {"inter-block-cache", cfg.InterBlockCache, def.InterBlockCache, true}, - {"pruning", cfg.Pruning, def.Pruning, true}, - {"pruning-keep-recent", cfg.PruningKeepRecent, def.PruningKeepRecent, true}, - {"pruning-interval", cfg.PruningInterval, def.PruningInterval, true}, - {"concurrency-workers", cfg.ConcurrencyWorkers, def.ConcurrencyWorkers, true}, - {"occ-enabled", cfg.OccEnabled, def.OccEnabled, true}, - {"halt-height", cfg.HaltHeight, def.HaltHeight, false}, - {"freeze-height", cfg.FreezeHeight, def.FreezeHeight, false}, - {"halt-time", cfg.HaltTime, def.HaltTime, false}, - {"min-retain-blocks", cfg.MinRetainBlocks, def.MinRetainBlocks, false}, - {"compaction-interval", cfg.CompactionInterval, def.CompactionInterval, false}, - - // The remaining plain casts. Every row from here down has a declared default equal to its - // getter's zero, so none diverges today, and each is here for the reason api.enable is. This - // table is the only thing tying these sections' absent-key resolution to their declared - // defaults, since none of them is wired to CheckAbsent. - {"rosetta.enable", cfg.Rosetta.Enable, def.Rosetta.Enable, false}, - {"rosetta.offline", cfg.Rosetta.Offline, def.Rosetta.Offline, false}, - {"grpc-web.enable-unsafe-cors", cfg.GRPCWeb.EnableUnsafeCORS, def.GRPCWeb.EnableUnsafeCORS, false}, - { - "grpc.keepalive-permit-without-stream", - cfg.GRPC.KeepalivePermitWithoutStream, def.GRPC.KeepalivePermitWithoutStream, false, - }, - {"telemetry.service-name", cfg.Telemetry.ServiceName, def.Telemetry.ServiceName, false}, - {"telemetry.enable-hostname", cfg.Telemetry.EnableHostname, def.Telemetry.EnableHostname, false}, - { - "telemetry.enable-hostname-label", - cfg.Telemetry.EnableHostnameLabel, def.Telemetry.EnableHostnameLabel, false, - }, - { - "telemetry.enable-service-label", - cfg.Telemetry.EnableServiceLabel, def.Telemetry.EnableServiceLabel, false, - }, - - // index-events resolves to a []string, which is why the comparison is reflect.DeepEqual rather - // than !=. Both sides are nil today, so it is a false row. - {"index-events", cfg.IndexEvents, def.IndexEvents, false}, - // The guarded read. Its absent value is the declared default, which is the property the - // guard exists to provide. - { - "grpc-web.max-open-connections", - cfg.GRPCWeb.MaxOpenConnections, def.GRPCWeb.MaxOpenConnections, false, - }, - } { - covered[c.key] = true - if got := !reflect.DeepEqual(c.absent, c.declared); got != c.diverges { - verb := "no longer diverges from" - if !c.diverges { - verb = "now diverges from" - } - t.Errorf("%s %s its declared default: absent=%v declared=%v. If a guard was added or "+ - "removed, or a default moved onto the getter's zero, update the row and this table "+ - "in the same PR", c.key, verb, c.absent, c.declared) - } - } - - requireEveryManifestRowIsAnchored(t, covered) -} - -// requireEveryManifestRowIsAnchored holds the table above to the manifests it is meant to anchor. -// -// The rows are hand-listed, because the diverges column is a judgement about each key that nothing -// derives. What can be derived is which keys need a row at all, and this does that: a key added to any -// of the six manifests gets a row, a seed and a name record from the checks already wired, and would -// otherwise get no absent-key entry while this table is stated to be the only thing tying these -// sections to their declared defaults. -func requireEveryManifestRowIsAnchored(t *testing.T, covered map[string]bool) { - t.Helper() - - var missing []string - for _, manifest := range [][]configtest.KeySpec{ - apiKeys, rosettaKeys, grpcWebKeys, telemetryKeys, grpcKeys, baseConfigKeys, - } { - for _, spec := range manifest { - if !covered[spec.Key] { - missing = append(missing, spec.Key) - } - } - } - if len(missing) > 0 { - sort.Strings(missing) - t.Errorf("these manifest rows have no entry in the absent-key table above, so nothing ties "+ - "their absent-key resolution to the declared defaults:\n %s\n"+ - "Add a row with the diverges value the key actually has, which is a decision rather than "+ - "something this can fill in.", strings.Join(missing, "\n ")) - } -} - -// baseConfigKeys covers the thirteen keys GetConfig reads at the top level of app.toml, the ones -// written without a section header. FreezeHeight is checked; the other fields use bare viper getters. -var baseConfigKeys = []configtest.KeySpec{ - { - Key: "minimum-gas-prices", Path: "MinGasPrices", Cast: configtest.CastString, - Unguarded: true, - Why: "the declared default is 0.01usei and an absent key resolves empty, which is the " + - "spelling for accepting a transaction at any fee", - }, - { - Key: "inter-block-cache", Path: "InterBlockCache", Cast: configtest.CastBool, - Unguarded: true, - Why: "the declared default is true and an absent key resolves false, so the node reads " + - "every store access from disk", - }, - { - Key: "pruning", Path: "Pruning", Cast: configtest.CastString, Unguarded: true, - Why: "the declared default is nothing, meaning keep all history, and an absent key resolves " + - "empty, which is not one of the strategy names", - }, - { - Key: "pruning-keep-recent", Path: "PruningKeepRecent", Cast: configtest.CastString, - Unguarded: true, - Why: "the declared default is the string 0 and an absent key resolves empty", - }, - { - Key: "pruning-interval", Path: "PruningInterval", Cast: configtest.CastString, - Unguarded: true, - Why: "the declared default is the string 0 and an absent key resolves empty", - }, - { - Key: "halt-height", Path: "HaltHeight", Cast: configtest.CastUint64, Unguarded: true, - Why: "0 is both the declared default and the spelling for never halting, so this row states " + - "the key is read rather than recording a divergence", - }, - { - Key: "halt-time", Path: "HaltTime", Cast: configtest.CastUint64, Unguarded: true, - Why: "0 is both the declared default and the spelling for never halting", - }, - { - Key: "index-events", Path: "IndexEvents", Cast: configtest.CastStringSlice, Unguarded: true, - Why: "which events the node indexes; nil either way, and the only slice-cast row here, so " + - "it is where a value the cast turns into a one-element slice would show up", - }, - { - Key: "min-retain-blocks", Path: "MinRetainBlocks", Cast: configtest.CastUint64, - Unguarded: true, - Why: "0 is both the declared default and the spelling for retaining everything", - }, - { - Key: "compaction-interval", Path: "CompactionInterval", Cast: configtest.CastUint64, - Unguarded: true, - Why: "0 is both the declared default and the spelling for never compacting", - }, - { - Key: "concurrency-workers", Path: "ConcurrencyWorkers", Cast: configtest.CastInt, - Unguarded: true, - Why: "the declared default is derived from the machine and an absent key resolves 0, so the " + - "worker count a node runs with comes from the file or is nothing", - }, - { - Key: "occ-enabled", Path: "OccEnabled", Cast: configtest.CastBool, Unguarded: true, - Why: "the declared default is true and an absent key resolves false, so a node whose " + - "app.toml lacks the key executes without optimistic concurrency control", - }, - { - Key: "freeze-height", Path: "FreezeHeight", Cast: configtest.CastUint64, Unguarded: true, Checked: true, - Why: "0 is both the declared default and the spelling for allowing consensus to advance", - }, -} - -func readBaseConfig(t testing.TB) func(configtest.AppOpts) (any, error) { - return sectionOfGetConfig(t, func(c Config) any { return c.BaseConfig }) -} - -func FuzzBaseConfig(f *testing.F) { - seeds := configtest.NewSeeds(f, fuzzing.ConfigValue) - seedEveryRow(seeds, len(baseConfigKeys)) - - // One discriminating value per row, away from the value an absent key resolves to. - seeds.AddRow(uint(0), fuzzing.KindString, "0.5usei", int64(0), false) - seeds.AddRow(uint(1), fuzzing.KindBool, "", int64(0), true) - seeds.AddRow(uint(2), fuzzing.KindString, "everything", int64(0), false) - seeds.AddRow(uint(3), fuzzing.KindString, "500", int64(0), false) - seeds.AddRow(uint(4), fuzzing.KindString, "17", int64(0), false) - seeds.AddRow(uint(5), fuzzing.KindInt64, "", int64(9000000), false) - seeds.AddRow(uint(6), fuzzing.KindInt64, "", int64(1893456000), false) - seeds.AddRow(uint(7), fuzzing.KindString, "message.action", int64(0), false) - seeds.AddRow(uint(8), fuzzing.KindInt64, "", int64(200000), false) - seeds.AddRow(uint(9), fuzzing.KindInt64, "", int64(1000), false) - seeds.AddRow(uint(10), fuzzing.KindInt64, "", int64(7), false) - seeds.AddRow(uint(11), fuzzing.KindBool, "", int64(0), true) - seeds.AddRow(uint(12), fuzzing.KindInt64, "", int64(9000000), false) - seeds.AddRow(uint(12), fuzzing.KindInt64, "", int64(-1), false) - - configtest.CheckEveryRowHasADiscriminatingSeed(f, "base_config", readBaseConfig(f), - baseConfigKeys, seeds) - - f.Fuzz(func(t *testing.T, keyIdx uint, kind uint8, s string, n int64, b bool) { - spec := configtest.Pick(baseConfigKeys, keyIdx) - configtest.CheckRow(t, "base_config", readBaseConfig(t), spec, - fuzzing.ConfigValue(kind, s, n, b)) - }) -} - -func TestBaseConfigKeyNamesMatchTheRecordedNames(t *testing.T) { - configtest.CheckKeyNames(t, "base_config", baseConfigKeys) -} - -// TestBaseConfigManifestNamesEveryField enforces the manifest's claim, and records the one field -// that has no key. -// -// PruningKeepEvery carries a mapstructure tag of pruning-keep-every and a declared default of "0", -// and GetConfig never reads it. So no app.toml value reaches it through this reader, and the -// exemption below is the record of that rather than a gap in the manifest. It is the shape of thing -// a replacement manager would otherwise try to map a key onto. -func TestBaseConfigManifestNamesEveryField(t *testing.T) { - configtest.CheckManifestCoversEveryField(t, "base_config", DefaultConfig().BaseConfig, - baseConfigKeys, - "PruningKeepEvery", - ) -} - -// grpcKeys covers the three [grpc] keys read as plain casts. -// -// The section is where the guarding in this reader is most complete, which is why only three keys -// are rows. Eight others are read behind v.IsSet or through clampNonNegativeDuration and so resolve -// an absent key to the in-code default rather than to a zero; CheckRow would predict the wrong -// resolution for each, so they are driven by FuzzGetConfigGRPCDurationClamps and -// TestGetConfigGRPCAbsentReads and recorded by name below. -// -// Read this beside apiKeys. Both sections expose a listener with a connection ceiling and a message -// size ceiling, and here every ceiling is guarded while there none is. -var grpcKeys = []configtest.KeySpec{ - { - Key: "grpc.enable", Path: "Enable", Cast: configtest.CastBool, Unguarded: true, - Why: "the declared default is true and an absent key resolves false, so a node whose " + - "app.toml lacks the section serves no gRPC", - }, - { - Key: "grpc.address", Path: "Address", Cast: configtest.CastString, Unguarded: true, - Why: "the declared default is 0.0.0.0:9090 and an absent key resolves empty", - }, - { - Key: "grpc.keepalive-permit-without-stream", Path: "KeepalivePermitWithoutStream", - Cast: configtest.CastBool, Unguarded: true, - Why: "whether a client may ping with no active stream; false either way, so this row states " + - "the key is read rather than recording a divergence", - }, -} - -// grpcKeysWithTargetsOfTheirOwn are the [grpc] keys whose resolution a row cannot describe, recorded -// for their names alone. -// -// Six are read behind v.IsSet, so an absent key keeps the in-code default. The other two, -// max-connection-age and max-connection-age-grace, are read unconditionally through the clamp -// (config.go:551-552), and their absent value matches the declared default only because both -// defaults are 0. The clamp rescues a negative value and does nothing for an absent one, so they are -// unguarded reads whose clobber is invisible. TestGetConfigGRPCAbsentReads holds the two groups -// apart for that reason. -// -// What the record adds is narrower than it looks, and worth stating exactly. Each of these keys is a -// literal at its read site, so renaming one already reddens its clamp target. The record puts the -// operator-facing spelling in a reviewable diff, and it is what would catch the rename if any of -// these moved to a shared constant the way twenty-eight of app's thirty rows have, since then the -// row and the read site would move together and the behavioural target would stay green. -var grpcKeysWithTargetsOfTheirOwn = []configtest.KeyName{ - "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", -} - -func readGRPC(t testing.TB) func(configtest.AppOpts) (any, error) { - return sectionOfGetConfig(t, func(c Config) any { return c.GRPC }) -} - -func FuzzGRPCConfig(f *testing.F) { - seeds := configtest.NewSeeds(f, fuzzing.ConfigValue) - seedEveryRow(seeds, len(grpcKeys)) - - seeds.AddRow(uint(0), fuzzing.KindBool, "", int64(0), true) - seeds.AddRow(uint(1), fuzzing.KindString, "127.0.0.1:19090", int64(0), false) - seeds.AddRow(uint(2), fuzzing.KindBool, "", int64(0), true) - - configtest.CheckEveryRowHasADiscriminatingSeed(f, "grpc", readGRPC(f), grpcKeys, seeds, - grpcKeysWithTargetsOfTheirOwn...) - - f.Fuzz(func(t *testing.T, keyIdx uint, kind uint8, s string, n int64, b bool) { - spec := configtest.Pick(grpcKeys, keyIdx) - configtest.CheckRow(t, "grpc", readGRPC(t), spec, fuzzing.ConfigValue(kind, s, n, b)) - }) -} - -// TestGRPCKeyNamesMatchTheRecordedNames pins all eleven [grpc] key names, the three rows and the -// eight driven elsewhere. -// -// The eight had no record before this, because their target carries a local struct rather than a -// KeySpec table, so nothing held their spelling. That is the gap this closes. -func TestGRPCKeyNamesMatchTheRecordedNames(t *testing.T) { - configtest.CheckKeyNames(t, "grpc", grpcKeys, grpcKeysWithTargetsOfTheirOwn...) -} - -func TestGRPCManifestNamesEveryField(t *testing.T) { - configtest.CheckManifestCoversEveryField(t, "grpc", DefaultConfig().GRPC, grpcKeys, - // Guarded reads, so an absent key keeps the in-code default rather than clobbering it. - "MaxRecvMsgSize", - "MaxOpenConnections", - // Clamped reads: a negative resolves to the in-code default rather than passing through. - "MaxConnectionIdle", - "MaxConnectionAge", - "MaxConnectionAgeGrace", - "KeepaliveTime", - "KeepaliveTimeout", - "KeepaliveMinTime", - ) -} - -// TestGetConfigGRPCAbsentReads records what an absent [grpc] key resolves to, holding the guarded -// reads apart from the two that only look guarded. -// -// This is the assertion the exemptions in TestGRPCManifestNamesEveryField rest on. Without it that -// list would claim those fields are covered elsewhere with nothing checking it, and a guard removed -// from any of them would pass every check in this file. -// -// The split matters because the two groups fail for different reasons and a reader needs the right -// one. Six keys are read behind v.IsSet, so a guard is what returns the declared default and losing -// it is the failure. max-connection-age and max-connection-age-grace have no guard at all: they are -// read unconditionally and clamped, so an absent key resolves 0 and that happens to equal their -// declared default. Moving either default off 0 turns them into a visible clobber, which is a -// different event from a guard disappearing. -func TestGetConfigGRPCAbsentReads(t *testing.T) { - cfg, err := GetConfig(newAppViper(t, nil)) - if err != nil { - t.Fatalf("GetConfig: %v", err) - } - def := DefaultConfig().GRPC - got := cfg.GRPC - - // Read behind v.IsSet, so the guard is what restores the declared default. - for _, c := range []struct { - key string - absent, declared any - }{ - {"grpc.max-recv-msg-size", got.MaxRecvMsgSize, def.MaxRecvMsgSize}, - {"grpc.max-open-connections", got.MaxOpenConnections, def.MaxOpenConnections}, - {"grpc.max-connection-idle", got.MaxConnectionIdle, def.MaxConnectionIdle}, - {"grpc.keepalive-time", got.KeepaliveTime, def.KeepaliveTime}, - {"grpc.keepalive-timeout", got.KeepaliveTimeout, def.KeepaliveTimeout}, - {"grpc.keepalive-min-time", got.KeepaliveMinTime, def.KeepaliveMinTime}, - } { - if c.absent != c.declared { - t.Errorf("an absent %s resolved to %v rather than the declared %v, so its v.IsSet guard "+ - "is gone. That is the failure the guard exists to prevent, and config.go:519-521 says "+ - "why: a node upgrading with an older app.toml stays bounded", c.key, c.absent, c.declared) - } - } - - // Read unconditionally and clamped. Nothing guards these, so the assertion is on the coincidence - // itself: the declared default is the getter's zero, which is why an absent key looks correct. - for _, c := range []struct { - key string - absent time.Duration - declared time.Duration - }{ - {"grpc.max-connection-age", got.MaxConnectionAge, def.MaxConnectionAge}, - {"grpc.max-connection-age-grace", got.MaxConnectionAgeGrace, def.MaxConnectionAgeGrace}, - } { - if c.declared != 0 { - t.Errorf("%s's declared default is now %v rather than 0. It is read unconditionally and "+ - "only clamped, so an absent key still resolves 0, which is now a clobber the manifest "+ - "should carry as a row rather than an exemption", c.key, c.declared) - continue - } - if c.absent != 0 { - t.Errorf("an absent %s resolved to %v rather than 0. That means a guard was added or the "+ - "clamp changed, which is a fine end state and moves this key into the guarded group "+ - "above", c.key, c.absent) - } - } -} - -// TestWiringMatchesTheRecord pins which checks each of this package's sections is wired to. -// -// Every other check here reports a change to what it asserts. None reports a check being removed, so -// this records the wiring and fails when it thins out. -func TestWiringMatchesTheRecord(t *testing.T) { - configtest.CheckWiring(t) -} diff --git a/sei-cosmos/server/config/config_test.go b/sei-cosmos/server/config/config_test.go index 849fb521ed..e7b44a52f3 100644 --- a/sei-cosmos/server/config/config_test.go +++ b/sei-cosmos/server/config/config_test.go @@ -2,11 +2,6 @@ package config import ( "bytes" -<<<<<<< HEAD -======= - "math" - "strings" ->>>>>>> 20eb288 (Add freeze mode for historical EVM RPC (#3910)) "testing" tmcfg "github.com/sei-protocol/sei-chain/sei-tendermint/config" @@ -132,35 +127,6 @@ func TestValidateBasic(t *testing.T) { }, expectErr: true, }, - { - name: "freeze height above maximum int64", - setupCfg: func() *Config { - cfg := DefaultConfig() - cfg.FreezeHeight = uint64(math.MaxInt64) + 1 - return cfg - }, - expectErr: true, - }, - { - name: "freeze and halt heights", - setupCfg: func() *Config { - cfg := DefaultConfig() - cfg.FreezeHeight = 100 - cfg.HaltHeight = 100 - return cfg - }, - expectErr: true, - }, - { - name: "freeze height and halt time", - setupCfg: func() *Config { - cfg := DefaultConfig() - cfg.FreezeHeight = 100 - cfg.HaltTime = 100 - return cfg - }, - expectErr: true, - }, } for _, tt := range tests { @@ -177,7 +143,8 @@ func TestValidateBasic(t *testing.T) { } func TestGetConfigRejectsNegativeFreezeHeight(t *testing.T) { - v := seedViperWithDefaultConfig(t) + v := viper.New() + v.Set("telemetry.global-labels", []interface{}{}) v.Set("freeze-height", -1) _, err := GetConfig(v) diff --git a/sei-cosmos/server/config/freeze_test.go b/sei-cosmos/server/config/freeze_test.go new file mode 100644 index 0000000000..8e3dcbc0c1 --- /dev/null +++ b/sei-cosmos/server/config/freeze_test.go @@ -0,0 +1,39 @@ +package config + +import ( + "math" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidateFreezeBackport(t *testing.T) { + tests := []struct { + name string + freezeHeight uint64 + haltHeight uint64 + haltTime uint64 + wantErr string + }{ + {name: "disabled"}, + {name: "enabled", freezeHeight: 100}, + {name: "height overflow", freezeHeight: uint64(math.MaxInt64) + 1, wantErr: "freeze-height must not exceed"}, + {name: "halt height", freezeHeight: 100, haltHeight: 100, wantErr: "cannot be combined"}, + {name: "halt time", freezeHeight: 100, haltTime: 100, wantErr: "cannot be combined"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := DefaultConfig() + cfg.FreezeHeight = tt.freezeHeight + cfg.HaltHeight = tt.haltHeight + cfg.HaltTime = tt.haltTime + err := cfg.ValidateFreeze() + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, tt.wantErr) + }) + } +} diff --git a/sei-cosmos/server/config/testdata/base_config.keys.golden b/sei-cosmos/server/config/testdata/base_config.keys.golden deleted file mode 100644 index b0a7b30124..0000000000 --- a/sei-cosmos/server/config/testdata/base_config.keys.golden +++ /dev/null @@ -1,14 +0,0 @@ -"minimum-gas-prices" -"inter-block-cache" -"pruning" -"pruning-keep-recent" -"pruning-interval" -"halt-height" -"halt-time" -"index-events" -"min-retain-blocks" -"compaction-interval" -"concurrency-workers" -"occ-enabled" -"freeze-height" -# keys with a target of their own diff --git a/sei-cosmos/server/config/testdata/server_config.golden b/sei-cosmos/server/config/testdata/server_config.golden deleted file mode 100644 index 32fdcd7f3f..0000000000 --- a/sei-cosmos/server/config/testdata/server_config.golden +++ /dev/null @@ -1,153 +0,0 @@ -MinGasPrices = string("0.01usei") -Pruning = string("nothing") -PruningKeepRecent = string("0") -PruningKeepEvery = string("0") -PruningInterval = string("0") -HaltHeight = uint64(0) -FreezeHeight = uint64(0) -HaltTime = uint64(0) -MinRetainBlocks = uint64(0) -InterBlockCache = bool(true) -IndexEvents = -CompactionInterval = uint64(0) -ConcurrencyWorkers = -OccEnabled = bool(true) -Telemetry.ServiceName = string("") -Telemetry.Enabled = bool(true) -Telemetry.EnableHostname = bool(false) -Telemetry.EnableHostnameLabel = bool(false) -Telemetry.EnableServiceLabel = bool(false) -Telemetry.PrometheusRetentionTime = int64(7200) -Telemetry.GlobalLabels = -API.Enable = bool(false) -API.Swagger = bool(true) -API.EnableUnsafeCORS = bool(false) -API.Address = string("tcp://0.0.0.0:1317") -API.MaxOpenConnections = uint(1000) -API.RPCReadTimeout = uint(10) -API.RPCWriteTimeout = uint(0) -API.RPCMaxBodyBytes = uint(1000000) -GRPC.Enable = bool(true) -GRPC.Address = string("0.0.0.0:9090") -GRPC.MaxRecvMsgSize = int(4194304) -GRPC.MaxOpenConnections = uint(1000) -GRPC.MaxConnectionIdle = time.Duration(5m0s) -GRPC.MaxConnectionAge = time.Duration(0s) -GRPC.MaxConnectionAgeGrace = time.Duration(0s) -GRPC.KeepaliveTime = time.Duration(2h0m0s) -GRPC.KeepaliveTimeout = time.Duration(20s) -GRPC.KeepaliveMinTime = time.Duration(5m0s) -GRPC.KeepalivePermitWithoutStream = bool(false) -Rosetta.Address = string(":8080") -Rosetta.Blockchain = string("app") -Rosetta.Network = string("network") -Rosetta.Retries = int(3) -Rosetta.Enable = bool(false) -Rosetta.Offline = bool(false) -GRPCWeb.Enable = bool(true) -GRPCWeb.Address = string("0.0.0.0:9091") -GRPCWeb.EnableUnsafeCORS = bool(false) -GRPCWeb.MaxOpenConnections = uint(1000) -StateSync.SnapshotInterval = uint64(0) -StateSync.SnapshotKeepRecent = uint32(2) -StateSync.SnapshotDirectory = string("") -StateCommit.Enable = bool(true) -StateCommit.Directory = string("") -StateCommit.AsyncCommitBuffer = int(0) -StateCommit.WriteMode = types.WriteMode("memiavl_only") -StateCommit.WriteModeEnableAuto = bool(true) -StateCommit.MemIAVLConfig.AsyncCommitBuffer = int(100) -StateCommit.MemIAVLConfig.SnapshotKeepRecent = uint32(1) -StateCommit.MemIAVLConfig.SnapshotInterval = uint32(10000) -StateCommit.MemIAVLConfig.SnapshotMinTimeInterval = uint32(3600) -StateCommit.MemIAVLConfig.SnapshotWriterLimit = int(4) -StateCommit.MemIAVLConfig.SnapshotPrefetchThreshold = float64(0.8) -StateCommit.MemIAVLConfig.SnapshotWriteRateMBps = int(100) -StateCommit.FlatKVConfig.DataDir = string("") -StateCommit.FlatKVConfig.Fsync = bool(false) -StateCommit.FlatKVConfig.AsyncWriteBuffer = int(0) -StateCommit.FlatKVConfig.SnapshotInterval = uint32(10000) -StateCommit.FlatKVConfig.SnapshotKeepRecent = uint32(1) -StateCommit.FlatKVConfig.ExternalPruning = bool(false) -StateCommit.FlatKVConfig.EnablePebbleMetrics = bool(true) -StateCommit.FlatKVConfig.EnableReadWriteMetrics = bool(false) -StateCommit.FlatKVConfig.AccountDBConfig.DataDir = string("") -StateCommit.FlatKVConfig.AccountDBConfig.EnableMetrics = bool(true) -StateCommit.FlatKVConfig.AccountDBConfig.EnableReadWriteMetrics = bool(false) -StateCommit.FlatKVConfig.AccountDBConfig.MetricsScrapeInterval = time.Duration(10s) -StateCommit.FlatKVConfig.AccountCacheConfig.ShardCount = uint64(8) -StateCommit.FlatKVConfig.AccountCacheConfig.MaxSize = uint64(1073741824) -StateCommit.FlatKVConfig.AccountCacheConfig.EstimatedOverheadPerEntry = uint64(250) -StateCommit.FlatKVConfig.AccountCacheConfig.MetricsName = string("") -StateCommit.FlatKVConfig.AccountCacheConfig.MetricsScrapeInterval = time.Duration(0s) -StateCommit.FlatKVConfig.CodeDBConfig.DataDir = string("") -StateCommit.FlatKVConfig.CodeDBConfig.EnableMetrics = bool(true) -StateCommit.FlatKVConfig.CodeDBConfig.EnableReadWriteMetrics = bool(false) -StateCommit.FlatKVConfig.CodeDBConfig.MetricsScrapeInterval = time.Duration(10s) -StateCommit.FlatKVConfig.CodeCacheConfig.ShardCount = uint64(8) -StateCommit.FlatKVConfig.CodeCacheConfig.MaxSize = uint64(536870912) -StateCommit.FlatKVConfig.CodeCacheConfig.EstimatedOverheadPerEntry = uint64(250) -StateCommit.FlatKVConfig.CodeCacheConfig.MetricsName = string("") -StateCommit.FlatKVConfig.CodeCacheConfig.MetricsScrapeInterval = time.Duration(0s) -StateCommit.FlatKVConfig.StorageDBConfig.DataDir = string("") -StateCommit.FlatKVConfig.StorageDBConfig.EnableMetrics = bool(true) -StateCommit.FlatKVConfig.StorageDBConfig.EnableReadWriteMetrics = bool(false) -StateCommit.FlatKVConfig.StorageDBConfig.MetricsScrapeInterval = time.Duration(10s) -StateCommit.FlatKVConfig.StorageCacheConfig.ShardCount = uint64(8) -StateCommit.FlatKVConfig.StorageCacheConfig.MaxSize = uint64(4294967296) -StateCommit.FlatKVConfig.StorageCacheConfig.EstimatedOverheadPerEntry = uint64(250) -StateCommit.FlatKVConfig.StorageCacheConfig.MetricsName = string("") -StateCommit.FlatKVConfig.StorageCacheConfig.MetricsScrapeInterval = time.Duration(0s) -StateCommit.FlatKVConfig.MiscDBConfig.DataDir = string("") -StateCommit.FlatKVConfig.MiscDBConfig.EnableMetrics = bool(true) -StateCommit.FlatKVConfig.MiscDBConfig.EnableReadWriteMetrics = bool(false) -StateCommit.FlatKVConfig.MiscDBConfig.MetricsScrapeInterval = time.Duration(10s) -StateCommit.FlatKVConfig.MiscCacheConfig.ShardCount = uint64(8) -StateCommit.FlatKVConfig.MiscCacheConfig.MaxSize = uint64(536870912) -StateCommit.FlatKVConfig.MiscCacheConfig.EstimatedOverheadPerEntry = uint64(250) -StateCommit.FlatKVConfig.MiscCacheConfig.MetricsName = string("") -StateCommit.FlatKVConfig.MiscCacheConfig.MetricsScrapeInterval = time.Duration(0s) -StateCommit.FlatKVConfig.MetadataDBConfig.DataDir = string("") -StateCommit.FlatKVConfig.MetadataDBConfig.EnableMetrics = bool(true) -StateCommit.FlatKVConfig.MetadataDBConfig.EnableReadWriteMetrics = bool(false) -StateCommit.FlatKVConfig.MetadataDBConfig.MetricsScrapeInterval = time.Duration(10s) -StateCommit.FlatKVConfig.MetadataCacheConfig.ShardCount = uint64(8) -StateCommit.FlatKVConfig.MetadataCacheConfig.MaxSize = uint64(536870912) -StateCommit.FlatKVConfig.MetadataCacheConfig.EstimatedOverheadPerEntry = uint64(250) -StateCommit.FlatKVConfig.MetadataCacheConfig.MetricsName = string("") -StateCommit.FlatKVConfig.MetadataCacheConfig.MetricsScrapeInterval = time.Duration(0s) -StateCommit.FlatKVConfig.ReaderThreadsPerCore = float64(2) -StateCommit.FlatKVConfig.ReaderConstantThreadCount = int(0) -StateCommit.FlatKVConfig.ReaderPoolQueueSize = int(1024) -StateCommit.FlatKVConfig.MiscPoolThreadsPerCore = float64(4) -StateCommit.FlatKVConfig.MiscConstantThreadCount = int(0) -StateCommit.FlatKVConfig.LtHashThreadsPerCore = float64(1) -StateCommit.HistoricalProofMaxInFlight = int(1) -StateCommit.HistoricalProofRateLimit = float64(1) -StateCommit.HistoricalProofBurst = int(1) -StateCommit.HashLogger.Enable = bool(true) -StateCommit.HashLogger.Directory = string("") -StateCommit.HashLogger.BlocksToRetain = uint(0) -StateCommit.HashLogger.TargetFileSize = uint(16777216) -StateCommit.HashLogger.MaxDiskSize = uint(17179869184) -StateCommit.HashLogger.Version = string("") -StateStore.Enable = bool(true) -StateStore.DBDirectory = string("") -StateStore.Backend = string("pebbledb") -StateStore.AsyncWriteBuffer = int(100) -StateStore.KeepRecent = int(100000) -StateStore.PruneIntervalSeconds = int(600) -StateStore.ImportNumWorkers = int(1) -StateStore.EnableReadWriteMetrics = bool(false) -StateStore.KeepLastVersion = bool(true) -StateStore.UseDefaultComparer = bool(false) -StateStore.SnapshotEnable = bool(false) -StateStore.SnapshotInterval = int64(0) -StateStore.SnapshotKeepRecent = int(0) -StateStore.SnapshotMinTimeInterval = time.Duration(0s) -StateStore.ExternalPruning = bool(false) -StateStore.EVMSplit = bool(false) -StateStore.EVMDBDirectory = string("") -StateStore.SeparateEVMSubDBs = bool(false) -Genesis.StreamImport = bool(false) -Genesis.GenesisStreamFile = string("") diff --git a/sei-tendermint/internal/blocksync/reactor_test.go b/sei-tendermint/internal/blocksync/reactor_test.go index 870d7ea668..d6e3dcdb42 100644 --- a/sei-tendermint/internal/blocksync/reactor_test.go +++ b/sei-tendermint/internal/blocksync/reactor_test.go @@ -462,7 +462,6 @@ func TestAutoRestartIfBehind(t *testing.T) { } } -<<<<<<< HEAD func makeValidationFailurePair( ctx context.Context, t *testing.T, @@ -619,7 +618,8 @@ func TestPoolRoutine_RetriesAfterValidationFailure(t *testing.T) { } } } -======= +} + func TestAutoRestartStopsAtFreezeBoundary(t *testing.T) { synctest.Test(t, func(t *testing.T) { const freezeHeight = uint64(101) @@ -642,7 +642,6 @@ func TestAutoRestartStopsAtFreezeBoundary(t *testing.T) { syncer.autoRestartIfBehind(t.Context(), blockPool) utilsrequire.False(t, restart.Load()) }) ->>>>>>> 20eb288 (Add freeze mode for historical EVM RPC (#3910)) } func TestQueryResponder_ServesBlockRequestsWhenBlockSyncDisabled(t *testing.T) { diff --git a/sei-tendermint/node/node.go b/sei-tendermint/node/node.go index 109d97d8a0..2e74d9b133 100644 --- a/sei-tendermint/node/node.go +++ b/sei-tendermint/node/node.go @@ -2,6 +2,7 @@ package node import ( "context" + "errors" "fmt" "math" "net" @@ -14,6 +15,7 @@ import ( "github.com/prometheus/client_golang/prometheus/promhttp" "go.opentelemetry.io/otel/sdk/trace" + abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" atypes "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/config" "github.com/sei-protocol/sei-chain/sei-tendermint/crypto" @@ -45,43 +47,6 @@ import ( _ "github.com/lib/pq" // provide the psql db driver ) -<<<<<<< HEAD -======= -type chainIDGatherer struct{ chainID string } - -func (g chainIDGatherer) Gather() ([]*dto.MetricFamily, error) { - metricFamilies, err := prometheus.DefaultGatherer.Gather() - if err != nil { - return nil, err - } - for _, metricFamily := range metricFamilies { - for _, metric := range metricFamily.Metric { - if hasMetricLabel(metric, "chain_id") { - continue - } - labels := slices.Clone(metric.Label) - labels = append(labels, &dto.LabelPair{ - Name: proto.String("chain_id"), - Value: proto.String(g.chainID), - }) - slices.SortFunc(labels, func(a, b *dto.LabelPair) int { - return strings.Compare(a.GetName(), b.GetName()) - }) - metric.Label = labels - } - } - return metricFamilies, nil -} - -func hasMetricLabel(metric *dto.Metric, name string) bool { - for _, label := range metric.GetLabel() { - if label.GetName() == name { - return true - } - } - return false -} - func validateFreezeHeight(freezeHeight uint64, initialHeight, stateHeight, blockStoreHeight, appHeight int64) error { if freezeHeight == 0 { return nil @@ -107,7 +72,6 @@ func validateFreezeHeight(freezeHeight uint64, initialHeight, stateHeight, block return nil } ->>>>>>> 20eb288 (Add freeze mode for historical EVM RPC (#3910)) // nodeImpl is the highest level interface to a full Tendermint node. // It includes all configuration information and running services. type nodeImpl struct { @@ -157,15 +121,8 @@ func makeNode( consensusPolicy types.ConsensusPolicy, nodeOptions ...Option, ) (_ local.NodeService, err error) { -<<<<<<< HEAD - var cancel context.CancelFunc -======= opts := resolveOptions(nodeOptions...) - var ( - cancel context.CancelFunc - node *nodeImpl - ) ->>>>>>> 20eb288 (Add freeze mode for historical EVM RPC (#3910)) + var cancel context.CancelFunc ctx, cancel = context.WithCancel(ctx) closers := []closer{convertCancelCloser(cancel)} defer func() { @@ -194,11 +151,17 @@ func makeNode( if err != nil { return nil, fmt.Errorf("LoadStateFromDBOrGenesisDocProvider(): %w", err) } - if err := validateFreezeHeight(opts.freezeHeight, genDoc.InitialHeight, state.LastBlockHeight, blockStore.Height(), proxyApp.Info().LastBlockHeight); err != nil { - return nil, err - } - if opts.freezeHeight > 0 && cfg.AutobahnConfigFile != "" { - return nil, errors.New("freeze height is not supported with Autobahn") + if opts.freezeHeight > 0 { + info, err := proxyApp.Info(ctx, &abci.RequestInfo{}) + if err != nil { + return nil, err + } + if err := validateFreezeHeight(opts.freezeHeight, genDoc.InitialHeight, state.LastBlockHeight, blockStore.Height(), info.LastBlockHeight); err != nil { + return nil, err + } + if cfg.AutobahnConfigFile != "" { + return nil, errors.New("freeze height is not supported with Autobahn") + } } eventBus := eventbus.NewDefault() @@ -501,26 +464,10 @@ func makeNode( } // OnStart starts the Node. It implements service.Service. -<<<<<<< HEAD func (n *nodeImpl) OnStart(ctx context.Context) error { -======= -func (n *nodeImpl) OnStart(ctx context.Context) (err error) { - // If Start fails before giga is spawned, BaseService does not call OnStop - // and never cancels SpawnCritical — so BlockDB would otherwise leak. - // When giga has already been spawned, its wrapper closes BlockDB after - // Run observes the service-context cancel issued once OnStart returns. - gigaSpawned := false if n.freezeHeight > 0 { logger.Info("Freeze mode enabled", "freeze_height", n.freezeHeight) } - defer func() { - if err == nil || gigaSpawned { - return - } - _ = n.closeGigaBlockDB() - }() - ->>>>>>> 20eb288 (Add freeze mode for historical EVM RPC (#3910)) // EventBus and IndexerService must be started before the handshake because // we might need to index the txs of the replayed block as this might not have happened // when the node stopped last time (i.e. the node stopped or crashed after it saved the block