diff --git a/cmd/seid/cmd/configmanager/configmanager.go b/cmd/seid/cmd/configmanager/configmanager.go index dd32f12d14..c6e6eec499 100644 --- a/cmd/seid/cmd/configmanager/configmanager.go +++ b/cmd/seid/cmd/configmanager/configmanager.go @@ -1,73 +1,317 @@ -// Package configmanager is the selection seam between the legacy config -// loader and the (forthcoming) sei-config-backed manager, gated by the -// SEI_CONFIG_MANAGER environment variable. -// -// PR1 ships the seam only: LegacyConfigManager re-enters the unchanged -// legacy handler verbatim (the default), and SeiConfigManager is a -// not-yet-implemented stub that does not import the sei-config library. -// The v2 body lands in a follow-up PR. See PLT-775 and the canonical -// design (bdchatham-designs designs/config-manager/DESIGN.md). package configmanager import ( + "errors" "fmt" + "log/slog" + "os" + "path" + "runtime/debug" + "strings" "github.com/spf13/cobra" + "github.com/spf13/viper" + + seiconfig "github.com/sei-protocol/sei-config" + "github.com/sei-protocol/seilog" + "github.com/sei-protocol/sei-chain/sei-cosmos/client/flags" "github.com/sei-protocol/sei-chain/sei-cosmos/server" ) -// EnvVar is the experimental gate that selects the configuration manager. +var logger = seilog.NewLogger("cmd", "seid", "configmanager") + +// EnvVar gates which configuration manager seid uses. const EnvVar = "SEI_CONFIG_MANAGER" // ConfigManager resolves a seid node's configuration during PersistentPreRunE. -// -// Load-bearing contract: an implementation must leave both channels the app -// consumes — serverCtx.Config and serverCtx.Viper — populated exactly as the -// legacy path does, and must be all-or-nothing with respect to on-disk state -// (no partial config.toml/app.toml materialization on error). A v2 manager -// that authors the two files from the sei-config library must write BOTH -// atomically and then re-enter the legacy read tail so the channels are -// produced identically — it must not feed app.New from an in-memory struct. -// -// The Apply signature matches server.InterceptConfigsPreRunHandler so the -// legacy implementation forwards verbatim. This is an internal, -// single-consumer contract (only root.go calls it) and is free to grow — an -// explicit home dir or a Prepare/Apply split — when the v2 write-then-re-enter -// body lands in a follow-up PR; the node dir is resolvable from cmd, so the -// signature is sufficient for PR1's seam. +// An implementation must leave serverCtx.Config and serverCtx.Viper populated +// exactly as the legacy path does. The Apply signature matches +// server.InterceptConfigsPreRunHandler so the legacy manager forwards verbatim. type ConfigManager interface { Apply(cmd *cobra.Command, customAppConfigTemplate string, customAppConfig any) error } -// LegacyConfigManager is the default manager: it re-enters the unchanged -// legacy handler verbatim, so the legacy path stays byte-for-byte unaffected. +// LegacyConfigManager is the default manager. It forwards to the legacy handler +// unchanged, leaving the legacy path byte-for-byte unaffected. type LegacyConfigManager struct{} -// Apply delegates to the legacy interception handler unchanged. +// Apply forwards to the legacy interception handler unchanged. func (LegacyConfigManager) Apply(cmd *cobra.Command, customAppConfigTemplate string, customAppConfig any) error { return server.InterceptConfigsPreRunHandler(cmd, customAppConfigTemplate, customAppConfig) } -// SeiConfigManager will resolve configuration through the sei-config library -// behind SEI_CONFIG_MANAGER=v2. PR1 ships the seam only; the real body lands -// in a follow-up PR. It intentionally does not import sei-config. -type SeiConfigManager struct{} +// SeiConfigManager validates the config through the sei-config library, then +// re-enters the legacy handler on the operator's original files. It never +// writes, migrates, or refuses boot. +type SeiConfigManager struct { + // logger reports the advisory outcome, and a nil one means the package logger. + // Select and every other caller build the zero value, so the nil case is the + // production path rather than a fallback: the accessor below is what keeps it + // from being a nil dereference, which in Apply would refuse a boot the legacy + // path allowed. It exists so a test can read what Apply reported without + // reassigning package state that a parallel test could race. + logger *slog.Logger +} + +// log returns the logger to report through, and never returns nil. +func (m SeiConfigManager) log() *slog.Logger { + if m.logger != nil { + return m.logger + } + return logger +} + +// Apply validates the operator's config, re-enters the legacy handler on the original +// files, then reports what the validation found. Validation runs before re-entry so it +// reads the files the operator authored rather than the ones the handler generates. The +// reporting runs after so its lines are emitted at the log level the handler applies +// (seilog.SetDefaultLevel), not the pre-config default: without this, a node with +// log_level = "error" still gets the advisory lines and a higher default would drop them, +// which for a pass whose only output is operator-facing defeats it. The outcome is +// reported even when the handler errors, so a boot that fails still gets the advisory. +// Nothing in either step refuses a boot the legacy path would have allowed. +// +// The report is deliberately not deferred. Deferring it as written would be harmless, +// because reportAdvisory's recover sits in a closure one frame too deep to see a panic +// raised here. It stops being harmless the moment that recover is hoisted into +// reportAdvisory's own body, which is a plausible edit given validateAdvisory below uses +// exactly that idiom: a deferred report would then recover a panic from the legacy +// handler and return nil, turning a boot the legacy path aborts into a successful one. +// TestApplyPropagatesALegacyHandlerPanic fails on that combination. +func (m SeiConfigManager) Apply(cmd *cobra.Command, customAppConfigTemplate string, customAppConfig any) error { + out := validateAdvisory(cmd) + err := server.InterceptConfigsPreRunHandler(cmd, customAppConfigTemplate, customAppConfig) + reportAdvisory(m.log(), out) + return err +} + +// reportAdvisory logs an advisory outcome, containing a panic from the logging itself. +// +// Everything here is advisory, so the one promise this manager makes is that it cannot +// refuse a boot the legacy path would have allowed, and a panic escaping this reporter +// would do exactly that by propagating out of Apply into PersistentPreRunE. The pass that +// produced out has its own recover, so the remaining exposure is the log call, which the +// deferred recover below contains. logAdvisory is proven panic-free on every outcome, so +// this only fires for a logger broken independent of its arguments. +func reportAdvisory(lg *slog.Logger, out advisoryOutcome) { + // The recover below stays inside this closure. Hoisted into reportAdvisory's own body + // it would see a panic unwinding a caller's frame whenever this function is deferred, + // so a deferred report in Apply would recover the legacy handler's panic and boot a + // node the legacy path refuses. TestApplyPropagatesALegacyHandlerPanic fails on that + // pair, and neither half fails on its own. + defer func() { + if r := recover(); r != nil { + // A second panic, from logging the first, must not escape. The nested recover + // makes recording the recovered value and stack safe, and it is worth + // recording: a reporter that swallows its own failure blind is the case least + // debuggable from a node's logs. This mirrors what the pass captures for a + // panic in validateAdvisory. + defer func() { _ = recover() }() + lg.Error("config validation reporting panicked (advisory; recovered, node will boot)", + "panic", r, "stack", string(debug.Stack())) + } + }() + logAdvisory(lg, out) +} + +// advisoryOutcome is what the validation pass saw. The pass reports rather than logs +// so that it is observable, which nothing else here can be: a channel-parity or +// never-refuses-boot assertion holds just as well when the read always fails or the +// validation has quietly become a no-op, so something has to be able to see that the +// pass ran and what it found. reportAdvisory does the logging. +type advisoryOutcome struct { + // Home is the directory the pass read, empty when it never got that far. + Home string + // Stage names where the pass stopped, and is stageNone when it completed. It is + // what keeps the two failures separately reportable. + Stage stage + // Skipped records that there was nothing to validate: no home resolved, or no + // config on disk yet, which is the normal case on a fresh node. + Skipped bool + // Diagnostics are the rendered validation findings. + Diagnostics []string + // Err is a resolve or read failure, advisory like everything else here. + Err error + // Panic is a recovered panic value and Stack its origin. sei-config's read and + // validate fidelity is still being hardened, so a panic here is the case most + // likely to need debugging from a node's logs, and the value alone gives no origin. + Panic any + Stack []byte +} + +// stage names how far the advisory pass got. It is a typed constant rather than a +// string because it is an internal discriminator matched by name, so a mistyped value +// should be a compile error instead of a case that silently drops an operator warning. +type stage int -// Apply is not yet implemented in PR1. It returns a hard error rather than -// silently falling back to the legacy path, so a v2 invocation is observable. -func (SeiConfigManager) Apply(_ *cobra.Command, _ string, _ any) error { - return fmt.Errorf("%s=v2 not yet implemented (PR1 seam only)", EnvVar) +const ( + stageNone stage = iota // the pass completed + stageResolve // stopped resolving the home dir + stageRead // stopped reading the config +) + +// String names the stage for a log line. +func (s stage) String() string { + switch s { + case stageNone: + return "none" + case stageResolve: + return "resolve" + case stageRead: + return "read" + default: + return fmt.Sprintf("stage(%d)", int(s)) + } } -// Select returns the ConfigManager chosen by the SEI_CONFIG_MANAGER value: -// unset or "legacy" -> LegacyConfigManager (the default); "v2" -> -// SeiConfigManager; any other value -> error (never a silent fallback). -// getenv is injected for testability; production callers pass os.Getenv. +// validateAdvisory resolves the home dir, reads the on-disk config and validates it, +// reporting what it saw. Every outcome is advisory: a failure, or a panic in the +// sei-config read or validate, is captured and returned rather than propagated, so +// the pass can never change what the node boots on. Keeping this a distinct step from +// Apply is what lets the generate path add its authoring/render step as a sibling. +// +// It runs before the legacy handler, and that ordering is deliberate: what it reports +// on is the configuration a node operator authored, not the configuration seid just +// generated for itself. The consequence is that a brand-new node is not validated on +// its first boot, since there is nothing on disk yet and the handler writes the files +// afterwards, so the earliest a diagnostic can appear is the second start. // -// The value is matched exactly — no trimming or case-folding. This is -// deliberate: normalizing would broaden the gate, and the error names the -// legal tokens so an operator can self-correct. +// Running it after re-entry as well would also validate on boot #1, and it is +// deliberately not done yet, because sei-config today reports an error against a freshly +// generated default config (the pruning read-mapping gap the design tracks). Note this +// ordering DEFERS that spurious warning by one boot rather than avoiding it: from the +// second boot on, the pre-handler pass reads the same seid-generated, operator-untouched +// config and logs the pruning-gap diagnostic until the gap closes, so a v2 node that +// never touches its config still warns on every start. Validating after re-entry too +// would only add the same warning on boot #1. Closing the pruning gap is therefore a +// prerequisite for wider v2 rollout, not just for making validation fatal; revisit the +// two together. +func validateAdvisory(cmd *cobra.Command) (out advisoryOutcome) { + defer func() { + if r := recover(); r != nil { + out.Panic, out.Stack = r, debug.Stack() + } + }() + + home, err := resolveHomeDir(cmd) + if err != nil { + out.Stage, out.Err = stageResolve, err + return out + } + // An unresolved home would send the read at ./config relative to the process + // working directory, so it could validate some unrelated node's files and report + // diagnostics that have nothing to do with what this node boots on. The legacy + // reader treats an empty home the same way, so this is not a parity break, but a + // pass whose whole purpose is operator-facing diagnostics has to decline instead. + if home == "" { + out.Skipped = true + return out + } + out.Home = home + + cfg, err := seiconfig.ReadConfigFromDir(home) + if err != nil { + // A missing config is the normal fresh-node case: the legacy handler creates it. + if errors.Is(err, os.ErrNotExist) { + out.Skipped = true + return out + } + out.Stage, out.Err = stageRead, err + return out + } + + for _, d := range seiconfig.Validate(cfg).Diagnostics { + out.Diagnostics = append(out.Diagnostics, d.String()) + } + return out +} + +// maxLoggedDiagnostics bounds the rendered list in one log line. A badly broken +// config can produce a diagnostic per field, and count is what an operator alerts +// on, so the full set is left to be re-derived from the file rather than emitted as +// one unbounded line. +const maxLoggedDiagnostics = 10 + +// logAdvisory reports an outcome through seilog. Nothing here refuses boot. +func logAdvisory(lg *slog.Logger, out advisoryOutcome) { + switch { + case out.Panic != nil: + lg.Error("config validation panicked (advisory; recovered, node will boot)", + "panic", out.Panic, "stack", string(out.Stack)) + case out.Stage == stageResolve: + lg.Warn("could not resolve home dir for config validation (advisory)", "error", out.Err) + // Any other non-completing stage still reports, so a stage added without its own + // case above logs something rather than nothing. The phrasing is neutral for the + // same reason: naming a step here would misreport a stage added later, and the + // stage attribute carries which one it actually was. + case out.Stage != stageNone: + lg.Warn("config validation stopped early (advisory)", + "stage", out.Stage, "error", out.Err) + // An unresolved home is closer to a misconfiguration than to a quiet default, and + // it is reported so an operator who opted into v2 can tell a declined pass from a + // pass that ran and found nothing. Home is what separates the two skips: it is + // empty only when the home never resolved, and the missing-config skip below it + // is the ordinary fresh-node case, which stays quiet. + case out.Skipped && out.Home == "": + lg.Info("config validation skipped: no home dir resolved (advisory)") + // The pass completed and found nothing. Report at Info so an operator who opted into + // v2 can see it ran clean, distinct from the quiet fresh-node skip (Skipped with a + // home), which stays silent because the legacy handler is about to write those files. + case !out.Skipped && out.Stage == stageNone && len(out.Diagnostics) == 0: + lg.Info("config validation passed: no advisories (node will boot)", "home", out.Home) + } + + if len(out.Diagnostics) == 0 { + return + } + shown, omitted := capDiagnostics(out.Diagnostics) + // The home is reported because a resolveHomeDir that drifted from the legacy + // handler would have these diagnostics describe a directory the node is not + // booting on, and without the path in the line there is no way to tell from a log. + lg.Warn("advisory config validation diagnostics (not enforced; node will boot)", + "home", out.Home, "count", len(out.Diagnostics), "diagnostics", shown, "omitted", omitted) +} + +// capDiagnostics splits a diagnostic list into the part to render and the number left +// out. It is separate from logAdvisory so the arithmetic can be asserted directly: +// an off-by-one or an inverted omitted count is not visible in a log line anyone reads. +func capDiagnostics(diags []string) (shown []string, omitted int) { + if len(diags) <= maxLoggedDiagnostics { + return diags, 0 + } + return diags[:maxLoggedDiagnostics], len(diags) - maxLoggedDiagnostics +} + +// resolveHomeDir resolves --home the same way the legacy handler does +// (sei-cosmos/server/util.go), so v2 validates the directory the handler reads. +// +// TODO: this re-implements ~15 lines of the legacy handler's viper bootstrap +// (sei-cosmos/server/util.go). Extract an exported server.ResolveHomeDir(cmd) and call +// it from both sides once the resolver lands and this is load-bearing for more than +// diagnostics; until then TestResolveHomeDirAgreesWithTheLegacyHandler guards the drift. +func resolveHomeDir(cmd *cobra.Command) (string, error) { + v := viper.New() + if err := v.BindPFlags(cmd.Flags()); err != nil { + return "", err + } + if err := v.BindPFlags(cmd.PersistentFlags()); err != nil { + return "", err + } + exe, err := os.Executable() + if err != nil { + return "", err + } + v.SetEnvPrefix(path.Base(exe)) + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_")) + v.AutomaticEnv() + return v.GetString(flags.FlagHome), nil +} + +// Select maps SEI_CONFIG_MANAGER to a manager: unset or "legacy" -> Legacy, +// "v2" -> Sei, anything else -> error. The value is matched exactly (no +// trimming or case-folding) and never falls back silently. getenv is injected +// for tests; callers pass os.Getenv. func Select(getenv func(string) string) (ConfigManager, error) { switch v := getenv(EnvVar); v { case "", "legacy": diff --git a/cmd/seid/cmd/configmanager/configmanager_test.go b/cmd/seid/cmd/configmanager/configmanager_test.go index 7320346f67..eef90f4741 100644 --- a/cmd/seid/cmd/configmanager/configmanager_test.go +++ b/cmd/seid/cmd/configmanager/configmanager_test.go @@ -1,9 +1,26 @@ package configmanager import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + "sync" "testing" + "github.com/spf13/cobra" "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/sdk/trace" + + seiconfig "github.com/sei-protocol/sei-config" + + "github.com/sei-protocol/sei-chain/sei-cosmos/client/flags" + "github.com/sei-protocol/sei-chain/sei-cosmos/server" + serverconfig "github.com/sei-protocol/sei-chain/sei-cosmos/server/config" + "github.com/sei-protocol/sei-chain/testutil/configtest" ) // TestSelect covers the dispatch table: unset and "legacy" select the @@ -34,8 +51,440 @@ func TestSelect(t *testing.T) { } } -// TestSeiConfigManagerNotImplemented asserts the v2 stub fails hard rather -// than silently behaving like legacy (PR1 ships the seam only). -func TestSeiConfigManagerNotImplemented(t *testing.T) { - require.Error(t, (SeiConfigManager{}).Apply(nil, "", nil)) +// TestResolveHomeDir_Flag confirms resolveHomeDir reads the --home flag. That the +// value it returns is the same one the re-entered handler reads is a separate +// property, asserted in TestResolveHomeDirAgreesWithTheLegacyHandler. +func TestResolveHomeDir_Flag(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().String(flags.FlagHome, "", "") + require.NoError(t, cmd.Flags().Set(flags.FlagHome, "/tmp/seid-test-home")) + + got, err := resolveHomeDir(cmd) + require.NoError(t, err) + require.Equal(t, "/tmp/seid-test-home", got) +} + +// TestResolveHomeDirEnvAndPrecedence covers the two cells the flag case above does +// not: the home arriving through the environment, and an explicit flag outranking it. +// +// The key is derived from the running binary's basename rather than spelled as +// SEID_HOME, because that is how both resolveHomeDir and the legacy handler build it. +// A change that hardcoded "seid" would still satisfy a literal-key test while +// silently ceasing to answer the environment under any other binary name, and the +// test binary is never named seid, so deriving the key is what makes this real. +// +// This asserts resolveHomeDir alone, against literal expectations. Agreement with the +// home the legacy handler reads is the lockstep property, and it is asserted against +// the real handler in TestResolveHomeDirAgreesWithTheLegacyHandler. +func TestResolveHomeDirEnvAndPrecedence(t *testing.T) { + prefix, err := configtest.ServerEnvPrefix() + require.NoError(t, err) + envKey := configtest.ServerEnvKey(prefix, flags.FlagHome) + + t.Run("the environment supplies the home", func(t *testing.T) { + configtest.Isolate(t) + t.Setenv(envKey, "/tmp/seid-env-home") + + cmd := &cobra.Command{} + cmd.Flags().String(flags.FlagHome, "", "") + + got, err := resolveHomeDir(cmd) + require.NoError(t, err) + require.Equal(t, "/tmp/seid-env-home", got, + "an unchanged flag default ranks below AutomaticEnv, so %s resolves the home", envKey) + }) + + t.Run("an explicit flag outranks the environment", func(t *testing.T) { + configtest.Isolate(t) + t.Setenv(envKey, "/tmp/seid-env-home") + + cmd := &cobra.Command{} + cmd.Flags().String(flags.FlagHome, "", "") + require.NoError(t, cmd.Flags().Set(flags.FlagHome, "/tmp/seid-flag-home")) + + got, err := resolveHomeDir(cmd) + require.NoError(t, err) + require.Equal(t, "/tmp/seid-flag-home", got, + "a changed flag outranks the environment in viper's precedence") + }) +} + +// TestResolveHomeDirAgreesWithTheLegacyHandler is the lockstep assertion: the home v2 +// validates has to be the home the handler it re-enters actually reads. +// +// No test outside this one can reach that property. v2's channels are produced +// entirely by the legacy handler, so a resolveHomeDir that drifted from the handler +// would only cause the advisory read to skip or warn, which changes no channel and +// fails no parity assertion anywhere. The result is that v2 would report diagnostics +// about one directory while the node booted on another, which is the silent drift the +// advisory design cannot surface on its own. So it is asserted directly, against the +// real handler, for both ways a home arrives. +func TestResolveHomeDirAgreesWithTheLegacyHandler(t *testing.T) { + cases := []struct { + name string + setup func(t *testing.T, cmd *cobra.Command, root string) + }{ + {"home from the flag", func(t *testing.T, cmd *cobra.Command, root string) { + require.NoError(t, cmd.Flags().Set(flags.FlagHome, root)) + }}, + {"home from the environment", func(t *testing.T, cmd *cobra.Command, root string) { + prefix, err := configtest.ServerEnvPrefix() + require.NoError(t, err) + t.Setenv(configtest.ServerEnvKey(prefix, flags.FlagHome), root) + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + configtest.Isolate(t) + root := filepath.Join(t.TempDir(), "node") + require.NoError(t, os.MkdirAll(root, 0o750)) + + // A real StartCmd, so the flag set and its defaults are the ones seid ships. + cmd := server.StartCmd(nil, "/foobar", []trace.TracerProviderOption{}) + tc.setup(t, cmd, root) + + // Captured before the handler runs, which is both what Apply does and what makes + // this an assertion about resolveHomeDir. The handler ends in bindFlags, which + // writes the env-resolved value back onto the --home flag and marks it Changed + // (sei-cosmos/server/util.go), so a resolveHomeDir read afterwards would be + // reading that flag rather than resolving anything, and a version that had + // dropped SetEnvPrefix and AutomaticEnv entirely would still pass. + got, err := resolveHomeDir(cmd) + require.NoError(t, err) + + serverCtx := &server.Context{} + cmd.SetContext(context.WithValue(context.Background(), server.ServerContextKey, serverCtx)) + + // The real handler, which is what v2 re-enters and therefore what it must agree with. + require.NoError(t, LegacyConfigManager{}.Apply(cmd, + serverconfig.DefaultConfigTemplate, serverconfig.DefaultConfig())) + + handlerHome := serverCtx.Viper.GetString(flags.FlagHome) + require.Equal(t, root, handlerHome, + "the fixture did not drive the handler's resolution, so this comparison would be vacuous") + + require.Equal(t, handlerHome, got, + "resolveHomeDir has drifted from the legacy handler: v2 would validate %q while "+ + "the node boots on %q, and no parity assertion can see it", got, handlerHome) + }) + } +} + +// writeMinimalHome creates a node directory carrying the two files +// ReadConfigFromDir decodes, with the given contents, and returns its root. +// +// The files are written by hand rather than generated by the legacy creator, so the +// input is exactly what the test says it is. A generated config would carry +// machine-derived values (the worker counts, the hostname moniker) and template +// literals that move between releases, and an assertion about validation should not +// depend on either. +func writeMinimalHome(t *testing.T, configTOML, appTOML string) string { + t.Helper() + h := configtest.NewHome(t) + h.WriteConfigTOML(t, []byte(configTOML)) + h.WriteAppTOML(t, []byte(appTOML)) + return h.Root +} + +// homeCmd returns a command whose --home is set to root. +func homeCmd(t *testing.T, root string) *cobra.Command { + t.Helper() + cmd := &cobra.Command{} + cmd.Flags().String(flags.FlagHome, "", "") + require.NoError(t, cmd.Flags().Set(flags.FlagHome, root)) + return cmd +} + +// TestValidateAdvisoryReportsWhatItFound is the positive case for the validation +// pass, and it is the one claim the parity assertions cannot make. +// +// Channel parity and never-refuses-boot both hold just as well when the pass does +// nothing at all: if the read always failed, or validation degraded to returning no +// diagnostics after a sei-config bump, every one of those assertions would still +// pass. This one fails, because it names a finding the pass has to produce. +func TestValidateAdvisoryReportsWhatItFound(t *testing.T) { + configtest.Isolate(t) + + // app.toml carries no minimum-gas-prices, which sei-config reports against + // chain.min_gas_prices. A named field is asserted rather than a count, so an + // unrelated diagnostic appearing or disappearing does not make this pass or fail + // for the wrong reason. + root := writeMinimalHome(t, "mode = \"full\"\n", "") + + out := validateAdvisory(homeCmd(t, root)) + + require.Nil(t, out.Panic, "the pass panicked: %v\n%s", out.Panic, out.Stack) + require.NoError(t, out.Err, "the pass could not read the config it was pointed at") + require.False(t, out.Skipped, "the pass skipped a home that carries both config files") + require.Equal(t, root, out.Home, "the pass validated a different directory than it was given") + + require.NotEmpty(t, out.Diagnostics, + "validation produced no findings on a config missing a required field, so the "+ + "pass has become a no-op and every parity assertion elsewhere holds vacuously") + require.Contains(t, strings.Join(out.Diagnostics, "\n"), "chain.min_gas_prices", + "validation ran but did not report the missing required field") +} + +// TestValidateAdvisorySkipsAnUnresolvedHome pins the guard on an empty home. +// +// resolveHomeDir returns "" with no error when --home is absent, and a read rooted at +// "" resolves ./config relative to the process working directory. That would validate +// whatever node happens to live there and report diagnostics about a config this node +// is not booting on, which is worse than reporting nothing from a pass that exists to +// inform an operator. +func TestValidateAdvisorySkipsAnUnresolvedHome(t *testing.T) { + configtest.Isolate(t) + + out := validateAdvisory(&cobra.Command{}) // no --home registered at all + + require.True(t, out.Skipped, "an unresolved home must be declined, not read") + require.Empty(t, out.Home, "nothing may be recorded as validated") + require.Empty(t, out.Diagnostics, "a declined pass must not report findings") + require.NoError(t, out.Err) + require.Nil(t, out.Panic) +} + +// TestCapDiagnostics pins the truncation arithmetic at its boundary. A miscount here +// would misreport how much was left out of a log line, which is the kind of defect +// nobody notices by reading the output. +func TestCapDiagnostics(t *testing.T) { + diags := func(n int) []string { + out := make([]string, n) + for i := range out { + out[i] = fmt.Sprintf("d%d", i) + } + return out + } + cases := []struct { + name string + in int + wantShown int + wantOmitted int + }{ + {"none", 0, 0, 0}, + {"one", 1, 1, 0}, + {"exactly at the cap", maxLoggedDiagnostics, maxLoggedDiagnostics, 0}, + {"one over the cap", maxLoggedDiagnostics + 1, maxLoggedDiagnostics, 1}, + {"far over the cap", maxLoggedDiagnostics + 15, maxLoggedDiagnostics, 15}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + in := diags(tc.in) + shown, omitted := capDiagnostics(in) + + require.Len(t, shown, tc.wantShown) + require.Equal(t, tc.wantOmitted, omitted) + // Nothing may be invented or reordered, and the two parts must account for + // the whole list, which is what makes the reported count trustworthy. + require.Equal(t, tc.in, len(shown)+omitted, "shown plus omitted must be the whole list") + for i := range shown { + require.Equal(t, in[i], shown[i], "the rendered part must be the first %d in order", tc.wantShown) + } + }) + } +} + +// TestLogAdvisoryHandlesEveryOutcome exercises the branches no other test reaches: a +// recovered panic, a failure at each stage, and a truncated diagnostic list. It +// asserts the reporting path survives each shape rather than asserting log text, since +// the text is not a contract; a panic or nil dereference in the reporter would turn an +// advisory pass into a boot failure, which is the one thing it must never do. +// +// It reports through the production logger, which is what the zero-value manager +// resolves to, so the every-outcome table covers the logger a node actually boots with. +// The other tests here inject their own, and without this one nothing would construct +// the package logger at all: a nil or broken one would ship unnoticed, and a nil +// *slog.Logger panics on use rather than discarding, so it would refuse a boot. +func TestLogAdvisoryHandlesEveryOutcome(t *testing.T) { + lg := SeiConfigManager{}.log() + require.NotNil(t, lg, "the zero-value manager must resolve to a usable logger") + + many := make([]string, maxLoggedDiagnostics+3) + for i := range many { + many[i] = fmt.Sprintf("[ERROR] field%d: broken", i) + } + cases := []struct { + name string + out advisoryOutcome + }{ + {"zero value", advisoryOutcome{}}, + {"skipped", advisoryOutcome{Skipped: true}}, + {"resolve failed", advisoryOutcome{Stage: stageResolve, Err: errors.New("no home")}}, + {"read failed", advisoryOutcome{Stage: stageRead, Err: errors.New("bad toml")}}, + {"panicked", advisoryOutcome{Panic: "boom", Stack: []byte("goroutine 1 [running]:\n")}}, + {"panicked with no stack", advisoryOutcome{Panic: errors.New("boom")}}, + {"one diagnostic", advisoryOutcome{Home: "/tmp/n", Diagnostics: []string{"[ERROR] a: b"}}}, + {"more diagnostics than the cap", advisoryOutcome{Home: "/tmp/n", Diagnostics: many}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.NotPanics(t, func() { logAdvisory(lg, tc.out) }) + }) + } +} + +// TestReadConfigFromDirMissingIsErrNotExist pins the contract validateAdvisory's +// silent-skip depends on: a missing config file must yield an error that +// errors.Is(os.ErrNotExist) recognizes, so a fresh-home boot skips the advisory +// read quietly instead of logging a spurious warning. If sei-config ever swaps +// to a custom not-found error, this fails here rather than going noisy in prod. +func TestReadConfigFromDirMissingIsErrNotExist(t *testing.T) { + _, err := seiconfig.ReadConfigFromDir(t.TempDir()) + require.ErrorIs(t, err, os.ErrNotExist) +} + +// TestReportAdvisoryNeverEscapesWhenTheLoggerPanics exercises the manager's one hard +// promise: a panic in the advisory path must never propagate out of Apply into +// PersistentPreRunE and refuse a boot the legacy path would have allowed. +// +// The logger is the subsystem this defends against. validateAdvisory recovers its own +// panics (TestValidateAdvisoryReportsWhatItFound asserts a clean pass), and logAdvisory +// is proven panic-free on every outcome (TestLogAdvisoryHandlesEveryOutcome), so the +// remaining exposure is a logger broken independent of its arguments. A logger whose +// handler panics on every record makes both the reporting call and the recover handler's +// own log call panic; reportAdvisory must still return normally. +// +// The broken logger is passed in rather than assigned over the package one. Reassigning +// it would leave a future parallel test in this package racing the swap, and nothing here +// needs the package logger to change: the reporter takes the logger it reports through. +func TestReportAdvisoryNeverEscapesWhenTheLoggerPanics(t *testing.T) { + configtest.Isolate(t) + // A config missing a required field, so the pass produces a diagnostic and + // logAdvisory reaches a logger call rather than the quiet fresh-node skip. + root := writeMinimalHome(t, "mode = \"full\"\n", "") + out := validateAdvisory(homeCmd(t, root)) + + require.NotPanics(t, func() { reportAdvisory(slog.New(panickingHandler{}), out) }) +} + +// panickingHandler is a slog.Handler that panics on every record, standing in for a +// logger broken independent of its arguments (a bad handler, a writer on a closed fd). +type panickingHandler struct{} + +func (panickingHandler) Enabled(context.Context, slog.Level) bool { return true } +func (panickingHandler) Handle(context.Context, slog.Record) error { panic("logger handler is broken") } +func (panickingHandler) WithAttrs([]slog.Attr) slog.Handler { return panickingHandler{} } +func (panickingHandler) WithGroup(string) slog.Handler { return panickingHandler{} } + +// TestApplyRunsTheValidationPass asserts the one claim that distinguishes v2 from the +// legacy manager: that Apply actually runs the validation pass and reports it. +// +// Nothing else reaches this. The parity rows compare two runs of the same reader and +// still match if the pass never runs; the never-refuses-boot tests only assert that a +// boot succeeds; and the advisory tests call validateAdvisory and reportAdvisory +// directly. Deleting the two lines in Apply that call them makes v2 byte-identical to +// the legacy manager, and every one of those tests stays green. This one fails, because +// it drives the real Apply and names a finding the pass has to produce. +func TestApplyRunsTheValidationPass(t *testing.T) { + configtest.Isolate(t) + // A config missing a required field, so validation has a finding to report. app.toml + // is written as well: without it the read fails with ErrNotExist, the pass skips + // silently, and there would be nothing to distinguish that from the wiring being gone. + root := writeMinimalHome(t, "mode = \"full\"\n", "") + + // A real StartCmd, and one whose default home is non-empty. With an empty default an + // unresolved --home reports the no-home skip instead, so an assertion that merely + // counted records would hold even on a fixture that never reached the config. + cmd := server.StartCmd(nil, "/foobar", []trace.TracerProviderOption{}) + require.NoError(t, cmd.Flags().Set(flags.FlagHome, root)) + serverCtx := &server.Context{} + cmd.SetContext(context.WithValue(context.Background(), server.ServerContextKey, serverCtx)) + + capture := &capturingHandler{} + require.NoError(t, SeiConfigManager{logger: slog.New(capture)}.Apply(cmd, + serverconfig.DefaultConfigTemplate, serverconfig.DefaultConfig()), + "the fixture is meant to boot, so a failure here means the fixture, not the pass") + + // Named by diagnostic rather than by record count, and it is the same finding + // TestValidateAdvisoryReportsWhatItFound anchors on, so a sei-config change moves + // both together instead of leaving this one asserting that something was logged. + var found *slog.Record + var seen []string + for i := range capture.records { + line := renderRecord(capture.records[i]) + seen = append(seen, line) + if strings.Contains(line, "chain.min_gas_prices") { + found = &capture.records[i] + } + } + require.NotNil(t, found, + "Apply reported no diagnostic naming chain.min_gas_prices, so the validation pass "+ + "is not wired into it; records seen: %v", seen) + require.Equal(t, slog.LevelWarn, found.Level, + "the diagnostic line is the operator-facing one and has to survive a node's level") + require.Contains(t, renderRecord(*found), "home="+root, + "the line has to name the directory validated, or a drifted resolve is invisible") +} + +// TestApplyPropagatesALegacyHandlerPanic asserts the abort direction of the manager's +// promise: v2 must not turn a boot the legacy path refuses into one that succeeds. +// +// Every other test asserts the permissive direction, that a boot legacy allows still +// happens. This one exists because the advisory path holds a recover, and the reporting +// call in Apply is deliberately not deferred for that reason. A deferred report whose +// recover had been hoisted into reportAdvisory's own body would swallow the handler's +// panic and return nil, which is exactly this assertion failing. +// +// The fixture drives a real panic rather than simulating one. A home under a +// non-writable parent is absent as far as the handler's stat is concerned, so it takes +// the fresh-node branch, and the directory creation inside that branch then fails on +// permissions, which the legacy handler raises as a panic rather than an error. +func TestApplyPropagatesALegacyHandlerPanic(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("running as root, which ignores the directory permissions this relies on") + } + configtest.Isolate(t) + + readOnly := filepath.Join(t.TempDir(), "read-only") + require.NoError(t, os.MkdirAll(readOnly, 0o500)) + // Restored so the temp-dir cleanup can remove it. + t.Cleanup(func() { _ = os.Chmod(readOnly, 0o700) }) + + cmd := server.StartCmd(nil, "/foobar", []trace.TracerProviderOption{}) + require.NoError(t, cmd.Flags().Set(flags.FlagHome, filepath.Join(readOnly, "node"))) + serverCtx := &server.Context{} + cmd.SetContext(context.WithValue(context.Background(), server.ServerContextKey, serverCtx)) + + require.Panics(t, func() { + _ = SeiConfigManager{logger: slog.New(&capturingHandler{})}.Apply(cmd, + serverconfig.DefaultConfigTemplate, serverconfig.DefaultConfig()) + }, "a panic from the legacy handler must reach the caller rather than being recovered "+ + "into a successful boot") +} + +// capturingHandler records what was logged so a test can assert on it without +// reassigning the package logger. +type capturingHandler struct { + mu sync.Mutex + records []slog.Record +} + +// Enabled admits every level, so a record cannot be dropped by the level the legacy +// handler applies partway through Apply and leave an assertion looking at nothing. +func (h *capturingHandler) Enabled(context.Context, slog.Level) bool { return true } + +func (h *capturingHandler) Handle(_ context.Context, r slog.Record) error { + h.mu.Lock() + defer h.mu.Unlock() + // Cloned because slog only lends a record for the duration of the call. + h.records = append(h.records, r.Clone()) + return nil +} + +func (h *capturingHandler) WithAttrs([]slog.Attr) slog.Handler { return h } +func (h *capturingHandler) WithGroup(string) slog.Handler { return h } + +// renderRecord flattens a record's message and attributes into one line, so an +// assertion can name a value without knowing which attribute carries it. +func renderRecord(r slog.Record) string { + var b strings.Builder + b.WriteString(r.Message) + r.Attrs(func(a slog.Attr) bool { + b.WriteString(" ") + b.WriteString(a.Key) + b.WriteString("=") + b.WriteString(a.Value.String()) + return true + }) + return b.String() } diff --git a/cmd/seid/cmd/configmanager/doc.go b/cmd/seid/cmd/configmanager/doc.go new file mode 100644 index 0000000000..3db2733fe2 --- /dev/null +++ b/cmd/seid/cmd/configmanager/doc.go @@ -0,0 +1,23 @@ +// Package configmanager selects how seid loads its configuration, behind the +// SEI_CONFIG_MANAGER gate. Every manager boots the node identically; the only +// variable is an advisory validation pass that never rewrites a file and never +// refuses boot. +// +// SEI_CONFIG_MANAGER picks the manager: unset or "legacy" uses the legacy +// loader unchanged; "v2" uses the sei-config-backed manager. root.go calls +// Select once, during PersistentPreRunE. +// +// Both managers boot from the same two channels — serverCtx.Config and +// serverCtx.Viper — and v2 populates them exactly as legacy does: it re-enters +// the legacy reader on the operator's own files instead of rewriting them. Its +// validation pass is advisory because sei-config's read fidelity is still being +// hardened, and a gap in the model must not fail a valid node. +// +// The node boots on those two channels, never on the SeiConfig model; that is +// why differential tests suffice — proving v2's channels equal legacy's, which +// legacy is already trusted to boot on, is the whole correctness argument. +// +// Two things are deferred: making validation fatal, and authoring a canonical +// sei.toml to render the legacy files from (the generate path). See PLT-775 and +// the design (bdchatham-designs designs/config-manager/DESIGN.md). +package configmanager diff --git a/cmd/seid/cmd/configmanager_differential_test.go b/cmd/seid/cmd/configmanager_differential_test.go new file mode 100644 index 0000000000..85cfb38b52 --- /dev/null +++ b/cmd/seid/cmd/configmanager_differential_test.go @@ -0,0 +1,564 @@ +package cmd + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/stretchr/testify/require" + "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" + "github.com/sei-protocol/sei-chain/testutil/configtest" +) + +// This file compares what legacy resolves against what v2 resolves, on one home, +// through the two channels the rest of the boot reads. +// +// Be precise about what that currently proves. While v2 is a passthrough, both sides +// of every comparison here run the same legacy reader, so these rows cannot fail +// unless the advisory pass has a side effect. They are side-effect detectors, and the +// scaffolding the real comparison will run on, not yet a proof that two independent +// implementations agree: the second implementation does not exist yet. The rows that +// can fail on their own today are the ones that assert something other than equality +// between two runs of the same reader, namely that v2 writes nothing, that the +// advisory pass actually produced a finding, and that resolveHomeDir agrees with the +// handler. +// +// It runs on the same harness as the characterization suite (testutil/configtest), +// which is what makes the comparison trustworthy. Isolate pins the process +// environment, since the legacy reader answers to environment variables whose prefix +// follows the test binary's own name, and NewHome gives a node directory the test +// controls byte for byte, since the legacy reader writes into a home while reading it. +// See testutil/configtest/AGENTS.md. +// +// The verdicts stay require.Equal rather than the harness dump helpers. For the +// *tmcfg.Config struct that is a stronger comparison, because reflect.DeepEqual +// reaches unexported fields a rendered dump does not, and a dump is the right +// instrument for a recorded golden rather than for a relative legacy-versus-v2 +// equality. + +// errStopPreRun aborts the command after the config-resolution PreRunE, before +// StartCmd's RunE tries to boot a node. +var errStopPreRun = errors.New("stop after prerun") + +// runConfigManager runs mgr.Apply inside a StartCmd's PreRunE against home supplied +// via the --home flag, using seid's real app-config template, and returns the +// populated server context (the two channels start.go/app.New consume). +func runConfigManager(t *testing.T, mgr configmanager.ConfigManager, home *configtest.Home) *server.Context { + t.Helper() + return execConfigManager(t, mgr, startCmdForHome(t, home)) +} + +// startCmdForHome builds a StartCmd with --home set to the fixture home. +func startCmdForHome(t *testing.T, home *configtest.Home) *cobra.Command { + t.Helper() + cmd := server.StartCmd(nil, "/foobar", []trace.TracerProviderOption{}) + require.NoError(t, cmd.Flags().Set(flags.FlagHome, home.Root)) + return cmd +} + +// runConfigManagerEnvHome is runConfigManager's twin that supplies the home through +// the environment instead of --home, exercising the SetEnvPrefix/AutomaticEnv +// machinery that v2's resolveHomeDir mirrors from the legacy handler. The flag-driven +// path never touches it. +// +// The key comes from the harness rather than being spelled here, because the prefix +// is the running binary's basename and both the legacy handler and resolveHomeDir +// derive it that way. Hardcoding "seid" would pass under a binary named seid and say +// nothing about the resolution actually under test. +func runConfigManagerEnvHome(t *testing.T, mgr configmanager.ConfigManager, home *configtest.Home) *server.Context { + t.Helper() + prefix, err := configtest.ServerEnvPrefix() + require.NoError(t, err) + t.Setenv(configtest.ServerEnvKey(prefix, flags.FlagHome), home.Root) + + // Leave --home unset: an unchanged flag default ranks below AutomaticEnv in + // viper's precedence, so the env value is what resolves. + cmd := server.StartCmd(nil, "/foobar", []trace.TracerProviderOption{}) + return execConfigManager(t, mgr, cmd) +} + +// execConfigManager runs mgr.Apply on the happy path (Apply succeeds; boot is +// aborted with errStopPreRun) and returns the populated server context. The caller +// configures how home is supplied (flag vs env) on cmd beforehand. +func execConfigManager(t *testing.T, mgr configmanager.ConfigManager, cmd *cobra.Command) *server.Context { + t.Helper() + ctx, err := runManager(t, mgr, cmd) + require.NoError(t, err) + return ctx +} + +// runManager runs mgr.Apply inside cmd's PreRunE and returns the populated server +// context and the error Apply returned. Apply is the only boot-refusing call, so on +// the happy path it returns nil and boot is aborted with errStopPreRun; on a real +// config error it returns that error and runManager surfaces it unchanged. Advisory +// diagnostics go to seilog (not cmd's stderr), so they are not captured here — the +// invariants under test are the returned context and error, not the log text. +func runManager(t *testing.T, mgr configmanager.ConfigManager, cmd *cobra.Command) (*server.Context, error) { + t.Helper() + template, appCfg := initAppConfig() + // The happy-path PreRunE returns a non-nil sentinel (errStopPreRun), which cobra + // treats as a command error. SetErr covers the "Error:" line, but the ~50-line start + // usage block goes to the out writer, so silence usage and errors rather than dump it + // to stderr on every call (which multiplies under -fuzz). Advisory logs go to seilog. + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetErr(io.Discard) + + var applyErr error + cmd.PreRunE = func(c *cobra.Command, _ []string) error { + if applyErr = mgr.Apply(c, template, appCfg); applyErr != nil { + return applyErr + } + return errStopPreRun + } + + serverCtx := &server.Context{} + ctx := context.WithValue(context.Background(), server.ServerContextKey, serverCtx) + execErr := cmd.ExecuteContext(ctx) + if applyErr == nil { + require.ErrorIs(t, execErr, errStopPreRun) + } + return serverCtx, applyErr +} + +// seedDefaultConfig returns a home carrying a complete, realistic config (all Sei +// sections), generated by letting the legacy creator write into a fresh home. +func seedDefaultConfig(t *testing.T) *configtest.Home { + t.Helper() + home := configtest.NewHome(t) + _ = runConfigManager(t, configmanager.LegacyConfigManager{}, home) + return home +} + +// appendToAppTOML appends s to the home's app.toml. Every corpus mutation targets +// app.toml, so these three helpers name that rather than taking a path. +func appendToAppTOML(t *testing.T, home *configtest.Home, s string) { + t.Helper() + b, ok := home.Read(t, "app.toml") + require.True(t, ok, "app.toml must exist before it is appended to") + home.WriteAppTOML(t, append(b, []byte(s)...)) +} + +// prependToAppTOML prepends s to the home's app.toml. +func prependToAppTOML(t *testing.T, home *configtest.Home, s string) { + t.Helper() + b, ok := home.Read(t, "app.toml") + require.True(t, ok, "app.toml must exist before it is prepended to") + home.WriteAppTOML(t, append([]byte(s), b...)) +} + +// replaceInAppTOML replaces oldStr with newStr in the home's app.toml, asserting +// oldStr was present so a corpus mutation can never silently become a no-op. +func replaceInAppTOML(t *testing.T, home *configtest.Home, oldStr, newStr string) { + t.Helper() + b, ok := home.Read(t, "app.toml") + require.True(t, ok, "app.toml must exist before it is edited") + require.Contains(t, string(b), oldStr, "replace target %q not found — fixture would be vacuous", oldStr) + home.WriteAppTOML(t, []byte(strings.ReplaceAll(string(b), oldStr, newStr))) +} + +// corpusCase is one realistic on-disk config shape, applied to a freshly-seeded +// default home. It is the shared unit both the table-driven differential and the +// fuzz target consume, so the set of "interesting shapes" lives in one place. +type corpusCase struct { + name string + mutate func(t *testing.T, home *configtest.Home) +} + +// configCorpus is the single source of the config shapes the parity proof runs over. +// Each case mutates a default home in place; parity must hold for all of them because +// v2 re-enters the legacy reader regardless of what it read. +func configCorpus() []corpusCase { + return []corpusCase{ + {"default", func(t *testing.T, home *configtest.Home) {}}, + {"leading-comments-and-blanks", func(t *testing.T, home *configtest.Home) { + prependToAppTOML(t, home, "# corpus: a leading comment\n\n") + }}, + {"unknown-section", func(t *testing.T, home *configtest.Home) { + appendToAppTOML(t, home, "\n[sei-corpus-unknown]\nkey = \"value\"\n") + }}, + {"quoted-scalar", func(t *testing.T, home *configtest.Home) { + // A real numeric field written as a quoted string (sei-config #36's + // lenient-decode shape). Both paths re-enter the same legacy reader, so + // the channels match regardless — parity holds because v2's read is + // advisory. Coercion of quoted primitives is verified in sei-config's + // own tests; the differential only observes channel parity, not the + // advisory read's outcome. + replaceInAppTOML(t, home, "ss-keep-recent = 100000", `ss-keep-recent = "100000"`) + }}, + {"cosmos-only-write-mode", func(t *testing.T, home *configtest.Home) { + // The version-skew class: a config carrying the deprecated + // state-commit.sc-write-mode "cosmos_only". sei-config still accepts it + // as valid, so v2 raises no diagnostic today; the point here is that + // both managers read it identically (parity). It becomes a *caught* + // case only once fatal validation + a sei-config deprecation rule land. + replaceInAppTOML(t, home, `sc-write-mode = "memiavl_only"`, `sc-write-mode = "cosmos_only"`) + }}, + } +} + +// TestConfigManagerLegacyVsV2Differential is the shape the parity proof will take: the +// v2 manager must produce the SAME consumed config as the legacy path. While v2 passes +// through, it detects a side effect rather than proving agreement between two +// implementations (see the file header). v2 reads the +// config (to validate it) and then re-enters the legacy reader on the operator's +// ORIGINAL files — it does not rewrite — so the two paths read the SAME home and any +// difference is a real divergence, not a path artifact. +// +// It compares parsed semantics: +// - serverCtx.Config (the *tmcfg.Config the node runs on), and +// - serverCtx.Viper.AllSettings() (the AppOptions every Sei section reads via +// appOpts.Get), both at end-of-PersistentPreRunE and after the start.go +// chain-id mutation. +func TestConfigManagerLegacyVsV2Differential(t *testing.T) { + configtest.Isolate(t) + + // Both managers read the same home, carrying a complete realistic config. + home := seedDefaultConfig(t) + legacyCtx := runConfigManager(t, configmanager.LegacyConfigManager{}, home) + v2Ctx := runConfigManager(t, configmanager.SeiConfigManager{}, home) + + require.Equal(t, legacyCtx.Config, v2Ctx.Config, + "serverCtx.Config differs between legacy and v2") + require.Equal(t, legacyCtx.Viper.AllSettings(), v2Ctx.Viper.AllSettings(), + "serverCtx.Viper settings differ between legacy and v2") + + // The start.go chain-id mutation is identical on both vipers; assert parity + // holds after it too (covers the post-mutation snapshot). + const chainID = "differential-test-1" + legacyCtx.Viper.Set(flags.FlagChainID, chainID) + v2Ctx.Viper.Set(flags.FlagChainID, chainID) + require.Equal(t, legacyCtx.Viper.AllSettings(), v2Ctx.Viper.AllSettings(), + "settings diverge after the start.go chain-id mutation") +} + +// TestConfigManagerLegacyVsV2Differential_EnvHome runs the whole differential with the +// home arriving through the environment instead of --home, which the flag-driven case +// above never exercises: it is the only path here that goes through SetEnvPrefix and +// AutomaticEnv, on both managers. +// +// What it does not assert is that v2's own resolveHomeDir agrees with the handler. +// Both assertions below read the home out of the server context, which is the value +// the re-entered legacy handler resolved, so a drifted resolveHomeDir would only make +// the advisory read skip or warn and every assertion here would still pass. That +// property is asserted directly in the configmanager package, by +// TestResolveHomeDirAgreesWithTheLegacyHandler. +func TestConfigManagerLegacyVsV2Differential_EnvHome(t *testing.T) { + configtest.Isolate(t) + home := configtest.NewHome(t) + + // Populate a complete realistic config in home via the fresh-home legacy + // creator, driven entirely through the env var (no --home). + _ = runConfigManagerEnvHome(t, configmanager.LegacyConfigManager{}, home) + + legacyCtx := runConfigManagerEnvHome(t, configmanager.LegacyConfigManager{}, home) + v2Ctx := runConfigManagerEnvHome(t, configmanager.SeiConfigManager{}, home) + + // Non-vacuous guard: the env var actually drove resolution. If the key were + // wrong, both would fall back to StartCmd's "/foobar" default (and the legacy + // creator would fail writing under it) — this asserts the env path resolved to + // the fixture home, for both managers. + require.Equal(t, home.Root, v2Ctx.Viper.GetString(flags.FlagHome), + "env-provided home did not drive v2 resolution") + require.Equal(t, home.Root, legacyCtx.Viper.GetString(flags.FlagHome), + "env-provided home did not drive legacy resolution") + + require.Equal(t, legacyCtx.Config, v2Ctx.Config, + "serverCtx.Config differs between legacy and v2 on the env-home path") + require.Equal(t, legacyCtx.Viper.AllSettings(), v2Ctx.Viper.AllSettings(), + "serverCtx.Viper settings differ between legacy and v2 on the env-home path") +} + +// TestConfigManagerLegacyVsV2Differential_Corpus widens the parity proof from the +// single default fixture to a corpus of realistic on-disk shapes. Parity is by +// construction (v2 re-enters the legacy reader), so any shape an operator could +// present must produce identical channels — including shapes that exercise +// sei-config's own reader (quoted scalars, unknown keys), whose advisory read still +// must not perturb what the node boots on. +func TestConfigManagerLegacyVsV2Differential_Corpus(t *testing.T) { + for _, tc := range configCorpus() { + t.Run(tc.name, func(t *testing.T) { + configtest.Isolate(t) + home := seedDefaultConfig(t) + tc.mutate(t, home) + + legacyCtx := runConfigManager(t, configmanager.LegacyConfigManager{}, home) + v2Ctx := runConfigManager(t, configmanager.SeiConfigManager{}, home) + + require.Equal(t, legacyCtx.Config, v2Ctx.Config, + "serverCtx.Config differs between legacy and v2 (%s)", tc.name) + require.Equal(t, legacyCtx.Viper.AllSettings(), v2Ctx.Viper.AllSettings(), + "serverCtx.Viper settings differ between legacy and v2 (%s)", tc.name) + }) + } +} + +// TestConfigManagerV2AdvisoryNeverRefusesBoot pins the advisory invariant: on a valid +// config, v2 boots exactly as legacy does (Apply returns nil, both channels match), +// regardless of any diagnostics it prints. v2 adds observability, never a new boot +// outcome. +func TestConfigManagerV2AdvisoryNeverRefusesBoot(t *testing.T) { + configtest.Isolate(t) + home := seedDefaultConfig(t) + + v2Ctx, v2Err := runManager(t, configmanager.SeiConfigManager{}, startCmdForHome(t, home)) + require.NoError(t, v2Err, "advisory validation must never refuse boot on a valid config") + + legacyCtx := runConfigManager(t, configmanager.LegacyConfigManager{}, home) + require.Equal(t, legacyCtx.Config, v2Ctx.Config) + require.Equal(t, legacyCtx.Viper.AllSettings(), v2Ctx.Viper.AllSettings()) +} + +// TestConfigManagerV2FreshHomeBoots exercises the fresh-home first-boot path: v2's +// advisory read hits os.ErrNotExist (no config yet), silently skips, then re-enters +// the legacy handler, which creates the files. It must not refuse boot. Every other +// test pre-seeds the config, so this is the only cover of the ErrNotExist branch — +// the common case for a brand-new node, and the home here stays deliberately unseeded. +func TestConfigManagerV2FreshHomeBoots(t *testing.T) { + configtest.Isolate(t) + home := configtest.NewHome(t) + + v2Ctx, v2Err := runManager(t, configmanager.SeiConfigManager{}, startCmdForHome(t, home)) + require.NoError(t, v2Err, "v2 on a fresh home must not refuse boot on the missing-config read") + require.NotNil(t, v2Ctx.Config) +} + +// TestConfigManagerV2AdvisoryReadErrorMatchesLegacy pins the other half of the +// invariant: when the config is unreadable, v2 must not mask the failure or invent a +// new one. It logs an advisory read error (via seilog), then re-enters the legacy +// handler and returns exactly the error legacy returns. +func TestConfigManagerV2AdvisoryReadErrorMatchesLegacy(t *testing.T) { + configtest.Isolate(t) + home := seedDefaultConfig(t) + home.WriteConfigTOML(t, []byte("this is ] not [ valid toml")) + + _, legacyErr := runManager(t, configmanager.LegacyConfigManager{}, startCmdForHome(t, home)) + _, v2Err := runManager(t, configmanager.SeiConfigManager{}, startCmdForHome(t, home)) + + require.Error(t, legacyErr, "corrupt config.toml should fail the legacy reader") + // Asserted before the strings are compared: were v2 to swallow the failure, the + // comparison below would panic on a nil error rather than report the invariant + // that was actually broken. + require.Error(t, v2Err, "v2 must not mask the legacy boot error") + require.Equal(t, legacyErr.Error(), v2Err.Error(), + "v2 must return the same boot error as legacy, not mask or add one") +} + +// snapshotHome records every path under the home, with a content hash for each file. +// +// It walks the whole home rather than listing config/, because the claim it backs is +// that nothing is authored at boot, and a sei.toml at the home root or a file dropped +// into a subdirectory of config/ would both sit outside a single-level listing of +// config/. Directories are recorded as names so an added empty one still shows up. +func snapshotHome(t *testing.T, home *configtest.Home) map[string]string { + t.Helper() + + snap := map[string]string{} + err := filepath.WalkDir(home.Root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(home.Root, path) + if err != nil { + return err + } + if rel == "." { + return nil + } + if d.IsDir() { + snap[rel+"/"] = "dir" + return nil + } + b, err := os.ReadFile(path) //nolint:gosec // fixture path under the test's temp dir + if err != nil { + return err + } + snap[rel] = fmt.Sprintf("%d:%x", len(b), sha256.Sum256(b)) + return nil + }) + require.NoError(t, err) + return snap +} + +// TestConfigManagerV2WritesNothing pins the guarantee the rest of the file cannot +// see: v2 does not rewrite, migrate, or author anything at boot. +// +// Every other assertion here compares the resolved channels, so a v2-side write that +// leaves those channels alone passes all of them. Authoring a sei.toml, rewriting a +// file to nearly the same bytes, or adding anything to the config directory would all +// go unnoticed. This walks the whole home around the v2 run and compares, so the +// prose guarantee becomes an enforced one. +// +// The home is pre-seeded on purpose. A fresh home is the one case where writes are +// expected, since the legacy handler v2 re-enters is what creates the files, and +// TestConfigManagerV2FreshHomeBoots covers that path. +func TestConfigManagerV2WritesNothing(t *testing.T) { + configtest.Isolate(t) + home := seedDefaultConfig(t) + + before := snapshotHome(t, home) + _ = runConfigManager(t, configmanager.SeiConfigManager{}, home) + after := snapshotHome(t, home) + + require.Equal(t, before, after, + "v2 changed the node directory. It re-enters the legacy reader on the operator's own "+ + "files and must not author, migrate or rewrite anything at boot, and a write that "+ + "leaves the resolved channels alone is invisible to every other assertion here") +} + +// FuzzConfigManagerEnvOnlyKeyParity closes the one class the AllSettings comparison +// cannot reach. +// +// AllSettings enumerates only what viper knows structurally, from the files it read, +// its defaults, overrides and bound flags. A value carried solely by the environment +// for a key absent from app.toml has no enumerable existence, so it appears in neither +// AllSettings nor AllKeys, and every comparison above is blind to it. It is not +// invisible to the node: app.New reads through appOpts.Get, and AutomaticEnv resolves +// at Get time, so such a value does reach running code. +// +// That makes this the sharpest row in the file today, because environment resolution +// is exactly what the new manager changes. The assertion is therefore on Get rather +// than on the enumerable map, and it carries a non-vacuity guard: the probe asserts +// the value actually arrived, so a key that silently resolves to nil on both sides +// fails here instead of passing as agreement about nothing. +func FuzzConfigManagerEnvOnlyKeyParity(f *testing.F) { + // Keys absent from a generated app.toml, so the environment is their only carrier. + f.Add("sei-differential.absent-key", "from-env") + f.Add("state-store.ss-absent-probe", "17") + f.Add("evm.absent-probe", "true") + f.Add("sei-differential.dotted.deeper-probe", "nested") + + f.Fuzz(func(t *testing.T, key, value string) { + // A key has to survive the round trip into an environment variable name and + // back to be a probe at all. Anything else is out of scope rather than a + // finding, the same move the harness makes for values it cannot set. + if key == "" || !configtest.EnvValueIsSettable(key) || !configtest.EnvValueIsSettable(value) { + return + } + // An empty value is not a probe. viper's AutomaticEnv cannot tell an empty + // environment variable from an unset one, so Get returns nil either way and + // there is nothing for the parity assertion to compare. + if value == "" { + return + } + configtest.Isolate(t) + prefix, err := configtest.ServerEnvPrefix() + require.NoError(t, err) + envKey := configtest.ServerEnvKey(prefix, key) + if envKey == prefix+"_" || strings.ContainsAny(envKey, "= ") { + return // not a name the environment can carry + } + // A key that derives the same variable as a flag the legacy handler reads is not + // an env-only probe, and there are two distinct ways that happens. A StartCmd flag + // the harness marks Changed (--home) outranks AutomaticEnv, so Get returns the + // fixture root rather than the value under test (not-live). A flag the handler + // reads straight from viper under AutomaticEnv (log_level goes through + // lvl.UnmarshalText and errors on an unparseable level, tripping the require.NoError + // in execConfigManager) rejects the value before any comparison. Either way there + // is no divergence to compare, so skip when the derived variable collides. + // + // The two need different detection. The Changed-flag case is found by walking this + // command's flags. The AutomaticEnv case cannot be: log_level and log_format are + // registered on the root command, not on the bare StartCmd the harness builds, yet + // AutomaticEnv resolves them from the environment whether or not any flag is bound, + // so a flag walk never sees them. They are named explicitly for that reason. + // + // The comparison is on the derived variable name rather than the key, because + // ServerEnvKey upper-cases and rewrites separators: "HOME", "Home" and "home" all + // derive one variable, and a key test would let the first two through. Only the + // seed corpus runs under plain `go test`, so this is reachable by -fuzz, not CI. + probeCmd := server.StartCmd(nil, "/foobar", []trace.TracerProviderOption{}) + collidesWithFlag := false + checkName := func(name string) { + if envKey == configtest.ServerEnvKey(prefix, name) { + collidesWithFlag = true + } + } + probeCmd.Flags().VisitAll(func(f *pflag.Flag) { checkName(f.Name) }) + probeCmd.PersistentFlags().VisitAll(func(f *pflag.Flag) { checkName(f.Name) }) + checkName(flags.FlagLogLevel) + checkName(flags.FlagLogFormat) + if collidesWithFlag { + return + } + t.Setenv(envKey, value) + + home := seedDefaultConfig(t) + legacyCtx := runConfigManager(t, configmanager.LegacyConfigManager{}, home) + v2Ctx := runConfigManager(t, configmanager.SeiConfigManager{}, home) + + legacyGot := legacyCtx.Viper.Get(key) + v2Got := v2Ctx.Viper.Get(key) + + // Non-vacuity: the probe must actually be live on the legacy side, or the + // parity assertion below is two nils agreeing. AutomaticEnv resolves at Get + // time, so an env-carried value reaches Get even with the key absent from + // every file, which is the property that makes this row worth having. + require.Equal(t, value, legacyGot, + "the probe is not live: %s did not reach Get(%q) on the legacy path, so the "+ + "parity assertion below would compare nothing", envKey, key) + + require.Equal(t, legacyGot, v2Got, + "env-only key %q resolves differently between legacy and v2 (env %s=%q). This is "+ + "invisible to the AllSettings comparison, and app.New reads it through "+ + "appOpts.Get, so it reaches the running node", key, envKey, value) + }) +} + +// FuzzConfigManagerLegacyVsV2Parity is the exhaustive form of the corpus: it crosses +// every corpus shape with an arbitrary appended app.toml suffix, and asserts legacy +// and v2 reach the same outcome — identical channels when both succeed, the identical +// error when both fail. Parity is by construction, so the fuzzer should never find a +// divergence. Under `go test` (no -fuzz) it runs the seed corpus (each shape × a few +// suffixes), a deterministic differential in CI; under -fuzz it explores suffixes +// against every shape. +// +// The suffix is deliberately ungated: feeding bytes that do not form a valid TOML +// document is the point, since the property is that both managers fail the same way. +// The harness's writability guards belong to targets that build a document from a +// fuzzed value, not to one appending arbitrary bytes on purpose. +func FuzzConfigManagerLegacyVsV2Parity(f *testing.F) { + corpus := configCorpus() + for i := range corpus { + f.Add(uint(i), "") + f.Add(uint(i), "\n# a trailing comment\n") + f.Add(uint(i), "\nnot valid toml ][") + } + + // corpusIdx is unsigned so a fuzzed index maps to a case with a plain modulo + // — no sign guard, no math.MinInt negation edge. + f.Fuzz(func(t *testing.T, corpusIdx uint, appTOMLSuffix string) { + configtest.Isolate(t) + tc := corpus[corpusIdx%uint(len(corpus))] + + home := seedDefaultConfig(t) + tc.mutate(t, home) + appendToAppTOML(t, home, appTOMLSuffix) + + legacyCtx, legacyErr := runManager(t, configmanager.LegacyConfigManager{}, startCmdForHome(t, home)) + v2Ctx, v2Err := runManager(t, configmanager.SeiConfigManager{}, startCmdForHome(t, home)) + + if (legacyErr == nil) != (v2Err == nil) { + t.Fatalf("divergent outcome (case %q, suffix %q): legacyErr=%v v2Err=%v", tc.name, appTOMLSuffix, legacyErr, v2Err) + } + if legacyErr != nil { + require.Equal(t, legacyErr.Error(), v2Err.Error(), "divergent error (case %q, suffix %q)", tc.name, appTOMLSuffix) + return + } + require.Equal(t, legacyCtx.Config, v2Ctx.Config, "Config diverges (case %q, suffix %q)", tc.name, appTOMLSuffix) + require.Equal(t, legacyCtx.Viper.AllSettings(), v2Ctx.Viper.AllSettings(), "settings diverge (case %q, suffix %q)", tc.name, appTOMLSuffix) + }) +} diff --git a/cmd/seid/cmd/rootseam_config_fuzz_test.go b/cmd/seid/cmd/rootseam_config_fuzz_test.go index 2fc316cae4..e240fdbb1e 100644 --- a/cmd/seid/cmd/rootseam_config_fuzz_test.go +++ b/cmd/seid/cmd/rootseam_config_fuzz_test.go @@ -153,10 +153,11 @@ func TestPreRunSkipsConfigForInitPrefixedSubcommands(t *testing.T) { // a typo'd value stops the command rather than quietly booting the wrong manager, // which is what makes the flag safe to leave in a deployment. // -// Because this tree ships the v2 body as a stub, "v2" surfaces as a -// not-implemented error. That distinguishes "the gate rejected the value" from "the -// gate accepted it and the manager refused", and both are asserted so the difference -// survives the v2 body landing. +// What the gate does is all this pins. The v2 body is advisory, so it validates the +// config, logs what it finds, and re-enters the legacy reader on the operator's own +// files, which means an accepted value boots exactly as legacy does. The observable +// difference is therefore acceptance versus rejection rather than which manager ran, +// and the legacy-vs-v2 differential is what proves the two produce the same channels. func FuzzPreRunManagerSelection(f *testing.F) { f.Add("") f.Add("legacy") @@ -192,12 +193,12 @@ func FuzzPreRunManagerSelection(f *testing.F) { t.Fatal("the legacy manager must populate the boot channels") } case "v2": - if err == nil { - t.Fatal("v2 is accepted by the gate but its body is a stub, so the command must " + - "fail rather than silently falling back to legacy") + if err != nil { + t.Fatalf("%s=%q selects the v2 manager, which validates advisorily and re-enters "+ + "the legacy reader, so it must boot, got %v", configmanager.EnvVar, value, err) } - if !strings.Contains(err.Error(), configmanager.EnvVar) { - t.Fatalf("the v2 failure must name the gate, got %v", err) + if serverCtx.Viper == nil { + t.Fatal("the v2 manager must populate the boot channels") } default: if err == nil { @@ -257,21 +258,23 @@ func TestPreRunReadsTheManagerGatePerInvocation(t *testing.T) { t.Fatal("the legacy manager must populate the boot channels") } - // Second invocation in the same process, gate now set. It must reach the v2 manager, - // which this tree ships as a stub and therefore fails. Booting instead means the gate - // was answered from the first invocation rather than re-read. - if err := os.Setenv(configmanager.EnvVar, "v2"); err != nil { + // Second invocation in the same process, gate now set to a value the gate rejects. + // The rejection is the observable, and it belongs to the gate rather than to any + // manager's body: a cached answer would boot legacy cleanly here instead of failing. + // A legal "v2" cannot serve that purpose, because the v2 body is advisory and boots + // identically to legacy, so it would leave a re-read and a cached answer + // indistinguishable from outside. + if err := os.Setenv(configmanager.EnvVar, "v3"); err != nil { t.Fatalf("set %s: %v", configmanager.EnvVar, err) } second, secondErr := runRootPreRun(t, configtest.NewHome(t), "start") if secondErr == nil { - t.Fatalf("the second invocation has %s=v2 and must select the v2 manager, whose body is a "+ - "stub and fails. It booted cleanly instead, so the gate is being cached across "+ - "invocations and one process can no longer change managers between commands", - configmanager.EnvVar) + t.Fatalf("the second invocation has %s=v3, which the gate rejects, so it must fail. It "+ + "booted cleanly instead, so the gate is being cached across invocations and one "+ + "process can no longer change managers between commands", configmanager.EnvVar) } if !strings.Contains(secondErr.Error(), configmanager.EnvVar) { - t.Fatalf("the v2 failure must name the gate, got %v", secondErr) + t.Fatalf("the rejection must name the gate, got %v", secondErr) } if second.Viper != nil { t.Fatal("a rejected invocation must leave the boot channels unpopulated") diff --git a/go.mod b/go.mod index 908d0a56ce..7a951839ad 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.25.6 require ( cosmossdk.io/errors v1.0.2 github.com/99designs/keyring v1.2.1 - github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c + github.com/BurntSushi/toml v1.5.0 github.com/adlio/schema v1.3.9 github.com/alitto/pond v1.8.3 github.com/armon/go-metrics v0.4.1 @@ -70,6 +70,7 @@ require ( github.com/sasha-s/go-deadlock v0.3.5 github.com/segmentio/kafka-go v0.4.50 github.com/sei-protocol/goutils v0.0.2 + github.com/sei-protocol/sei-config v0.0.21 github.com/sei-protocol/sei-load v0.0.0-20251007135253-78fbdc141082 github.com/sei-protocol/sei-tm-db v0.0.5 github.com/sei-protocol/seilog v0.0.3 @@ -187,7 +188,7 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/snappy v1.0.0 // indirect diff --git a/go.sum b/go.sum index 76e0ca1a48..b7b5d4e41d 100644 --- a/go.sum +++ b/go.sum @@ -619,8 +619,8 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.0.0/go.mod h1:kgDm github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= -github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno= github.com/CloudyKit/jet/v6 v6.2.0/go.mod h1:d3ypHeIRNo2+XyqnGA8s+aphtcVpjP5hPwP/Lzo7Ro4= @@ -1084,8 +1084,8 @@ github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9 github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= @@ -1811,6 +1811,8 @@ github.com/sei-protocol/go-ethereum v1.15.7-sei-18 h1:de8M2eVCTF/UXgLZ7DyGMmQH3p github.com/sei-protocol/go-ethereum v1.15.7-sei-18/go.mod h1:+S9k+jFzlyVTNcYGvqFhzN/SFhI6vA+aOY4T5tLSPL0= github.com/sei-protocol/goutils v0.0.2 h1:Bfa7Sv+4CVLNM20QcpvGb81B8C5HkQC/kW1CQpIbXDA= github.com/sei-protocol/goutils v0.0.2/go.mod h1:iYE2DuJfEnM+APPehr2gOUXfuLuPsVxorcDO+Tzq9q8= +github.com/sei-protocol/sei-config v0.0.21 h1:0VXOz91v9YeM2dmpKi1XaIFy+p3EamDAJtnj02XLKIw= +github.com/sei-protocol/sei-config v0.0.21/go.mod h1:zcEdLzyIH2AyP0/QRBE3s4Y9eGn0C/qAUx1c4o4EROU= github.com/sei-protocol/sei-load v0.0.0-20251007135253-78fbdc141082 h1:f2sY8OcN60UL1/6POx+HDMZ4w04FTZtSScnrFSnGZHg= github.com/sei-protocol/sei-load v0.0.0-20251007135253-78fbdc141082/go.mod h1:V0fNURAjS6A8+sA1VllegjNeSobay3oRUW5VFZd04bA= github.com/sei-protocol/sei-tm-db v0.0.5 h1:3WONKdSXEqdZZeLuWYfK5hP37TJpfaUa13vAyAlvaQY=