diff --git a/config.go b/config.go index bdb7b1d..fb56a67 100644 --- a/config.go +++ b/config.go @@ -10,6 +10,10 @@ import "runtime" // CurrentVersion is the config schema version produced by this library. const CurrentVersion = 1 +// DefaultSnapshotInterval is the default Tendermint state-sync snapshot +// creation interval (in blocks) used when snapshot generation is enabled. +const DefaultSnapshotInterval = 2000 + // Pruning strategy constants. const ( PruningDefault = "default" diff --git a/config_test.go b/config_test.go index d8b6ab5..479a24e 100644 --- a/config_test.go +++ b/config_test.go @@ -10,7 +10,7 @@ import ( const testRPCAddr = "tcp://0.0.0.0:26657" func TestDefaultForMode_AllModesValid(t *testing.T) { - modes := []NodeMode{ModeValidator, ModeFull, ModeSeed, ModeArchive, ModeRPC, ModeIndexer} + modes := []NodeMode{ModeValidator, ModeFull, ModeSeed, ModeArchive} for _, mode := range modes { cfg := DefaultForMode(mode) if cfg.Mode != mode { @@ -200,7 +200,7 @@ func TestWriteReadRoundTrip(t *testing.T) { } func TestWriteReadRoundTrip_AllModes(t *testing.T) { - modes := []NodeMode{ModeValidator, ModeFull, ModeSeed, ModeArchive, ModeRPC, ModeIndexer} + modes := []NodeMode{ModeValidator, ModeFull, ModeSeed, ModeArchive} for _, mode := range modes { t.Run(string(mode), func(t *testing.T) { dir := t.TempDir() @@ -250,6 +250,146 @@ func TestApplyOverrides(t *testing.T) { } } +func TestApplyOverrides_Bool(t *testing.T) { + cfg := Default() + if err := ApplyOverrides(cfg, map[string]string{ + "network.p2p.allow_duplicate_ip": "true", + }); err != nil { + t.Fatalf("ApplyOverrides: %v", err) + } + if !cfg.Network.P2P.AllowDuplicateIP { + t.Error("expected AllowDuplicateIP to be true") + } + + if err := ApplyOverrides(cfg, map[string]string{ + "network.p2p.allow_duplicate_ip": "false", + }); err != nil { + t.Fatalf("ApplyOverrides: %v", err) + } + if cfg.Network.P2P.AllowDuplicateIP { + t.Error("expected AllowDuplicateIP to be false") + } +} + +func TestApplyOverrides_Uint(t *testing.T) { + cfg := Default() + if err := ApplyOverrides(cfg, map[string]string{ + "chain.halt_height": "999999", + }); err != nil { + t.Fatalf("ApplyOverrides: %v", err) + } + if cfg.Chain.HaltHeight != 999999 { + t.Errorf("halt_height: got %d, want 999999", cfg.Chain.HaltHeight) + } +} + +func TestApplyOverrides_Float(t *testing.T) { + cfg := Default() + if err := ApplyOverrides(cfg, map[string]string{ + "mempool.drop_priority_threshold": "0.75", + }); err != nil { + t.Fatalf("ApplyOverrides: %v", err) + } + if cfg.Mempool.DropPriorityThreshold != 0.75 { + t.Errorf("drop_priority_threshold: got %f, want 0.75", cfg.Mempool.DropPriorityThreshold) + } +} + +func TestApplyOverrides_Duration(t *testing.T) { + cfg := Default() + if err := ApplyOverrides(cfg, map[string]string{ + "network.rpc.timeout_broadcast_tx_commit": "30s", + }); err != nil { + t.Fatalf("ApplyOverrides: %v", err) + } + if cfg.Network.RPC.TimeoutBroadcastTxCommit.Duration != 30*time.Second { + t.Errorf("timeout_broadcast_tx_commit: got %v, want 30s", + cfg.Network.RPC.TimeoutBroadcastTxCommit.Duration) + } +} + +func TestApplyOverrides_Int64(t *testing.T) { + cfg := Default() + if err := ApplyOverrides(cfg, map[string]string{ + "state_sync.backfill_blocks": "500", + }); err != nil { + t.Fatalf("ApplyOverrides: %v", err) + } + if cfg.StateSync.BackfillBlocks != 500 { + t.Errorf("backfill_blocks: got %d, want 500", cfg.StateSync.BackfillBlocks) + } +} + +func TestApplyOverrides_UnknownKey(t *testing.T) { + cfg := Default() + err := ApplyOverrides(cfg, map[string]string{ + "totally.fake.key": "value", + }) + if err == nil { + t.Fatal("expected error for unknown key") + } +} + +func TestApplyOverrides_InvalidBool(t *testing.T) { + cfg := Default() + err := ApplyOverrides(cfg, map[string]string{ + "network.p2p.allow_duplicate_ip": "maybe", + }) + if err == nil { + t.Fatal("expected error for invalid bool value") + } +} + +func TestApplyOverrides_InvalidInt(t *testing.T) { + cfg := Default() + err := ApplyOverrides(cfg, map[string]string{ + "evm.http_port": "not_a_number", + }) + if err == nil { + t.Fatal("expected error for non-numeric int value") + } +} + +func TestApplyOverrides_InvalidDuration(t *testing.T) { + cfg := Default() + err := ApplyOverrides(cfg, map[string]string{ + "network.rpc.timeout_broadcast_tx_commit": "not_a_duration", + }) + if err == nil { + t.Fatal("expected error for invalid duration value") + } +} + +func TestApplyOverrides_Uint16Overflow(t *testing.T) { + cfg := Default() + err := ApplyOverrides(cfg, map[string]string{ + "network.p2p.max_connections": "70000", + }) + if err == nil { + t.Fatal("expected error for uint16 overflow (65535 max)") + } +} + +func TestApplyOverrides_Int32Overflow(t *testing.T) { + cfg := Default() + err := ApplyOverrides(cfg, map[string]string{ + "state_sync.fetchers": "3000000000", + }) + if err == nil { + t.Fatal("expected error for int32 overflow") + } +} + +func TestApplyOverrides_NegativeUint(t *testing.T) { + cfg := Default() + err := ApplyOverrides(cfg, map[string]string{ + "chain.halt_height": "-1", + }) + if err == nil { + t.Fatal("expected error for negative uint value") + } +} + func TestApplyOverrides_Empty(t *testing.T) { cfg := Default() original := cfg.EVM.HTTPPort @@ -339,8 +479,8 @@ func TestNodeMode_Validity(t *testing.T) { {ModeFull, true}, {ModeSeed, true}, {ModeArchive, true}, - {ModeRPC, true}, - {ModeIndexer, true}, + {"rpc", false}, + {"indexer", false}, {"bogus", false}, {"", false}, } @@ -352,7 +492,7 @@ func TestNodeMode_Validity(t *testing.T) { } func TestNodeMode_IsFullnodeType(t *testing.T) { - fullnodeTypes := []NodeMode{ModeFull, ModeArchive, ModeRPC, ModeIndexer} + fullnodeTypes := []NodeMode{ModeFull, ModeArchive} for _, m := range fullnodeTypes { if !m.IsFullnodeType() { t.Errorf("%s should be fullnode type", m) diff --git a/defaults.go b/defaults.go index b91c90d..0bf1f9b 100644 --- a/defaults.go +++ b/defaults.go @@ -2,6 +2,7 @@ package seiconfig import ( "os" + "strconv" "time" ) @@ -240,10 +241,6 @@ func applyModeOverrides(cfg *SeiConfig, mode NodeMode) { applyFullOverrides(cfg) case ModeArchive: applyArchiveOverrides(cfg) - case ModeRPC: - applyRPCOverrides(cfg) - case ModeIndexer: - applyIndexerOverrides(cfg) } } @@ -300,12 +297,15 @@ func applyArchiveOverrides(cfg *SeiConfig) { cfg.EVM.MaxTraceLookbackBlocks = -1 } -func applyRPCOverrides(cfg *SeiConfig) { - applyFullOverrides(cfg) -} - -func applyIndexerOverrides(cfg *SeiConfig) { - applyArchiveOverrides(cfg) +// SnapshotGenerationOverrides returns the config overrides needed when a node +// is configured to produce Tendermint state-sync snapshots. The controller +// applies these as ConfigIntent.Overrides alongside the mode defaults. +func SnapshotGenerationOverrides(keepRecent int32) map[string]string { + return map[string]string{ + "storage.pruning": PruningNothing, + "storage.snapshot_interval": strconv.FormatInt(DefaultSnapshotInterval, 10), + "storage.snapshot_keep_recent": strconv.FormatInt(int64(keepRecent), 10), + } } func defaultMoniker() string { diff --git a/intent_test.go b/intent_test.go index 697f845..9cb0028 100644 --- a/intent_test.go +++ b/intent_test.go @@ -5,7 +5,7 @@ import ( ) func TestValidateIntent_ValidModes(t *testing.T) { - modes := []NodeMode{ModeValidator, ModeFull, ModeSeed, ModeArchive, ModeRPC, ModeIndexer} + modes := []NodeMode{ModeValidator, ModeFull, ModeSeed, ModeArchive} for _, mode := range modes { result := ValidateIntent(ConfigIntent{Mode: mode}) if !result.Valid { @@ -136,7 +136,7 @@ func TestValidateIntent_ValidOverrideKey(t *testing.T) { // --------------------------------------------------------------------------- func TestResolveIntent_AllModes(t *testing.T) { - modes := []NodeMode{ModeValidator, ModeFull, ModeSeed, ModeArchive, ModeRPC, ModeIndexer} + modes := []NodeMode{ModeValidator, ModeFull, ModeSeed, ModeArchive} for _, mode := range modes { result, err := ResolveIntent(ConfigIntent{Mode: mode}) if err != nil { @@ -290,17 +290,17 @@ func TestResolveIncrementalIntent_PatchesExistingConfig(t *testing.T) { func TestResolveIncrementalIntent_ModeOverride(t *testing.T) { current := DefaultForMode(ModeFull) result, err := ResolveIncrementalIntent( - ConfigIntent{Mode: ModeRPC}, + ConfigIntent{Mode: ModeArchive}, current, ) if err != nil { t.Fatalf("unexpected error: %v", err) } - if result.Config.Mode != ModeRPC { - t.Errorf("expected mode RPC, got %q", result.Config.Mode) + if result.Config.Mode != ModeArchive { + t.Errorf("expected mode archive, got %q", result.Config.Mode) } - if result.Mode != ModeRPC { - t.Errorf("expected result.Mode RPC, got %q", result.Mode) + if result.Mode != ModeArchive { + t.Errorf("expected result.Mode archive, got %q", result.Mode) } } @@ -334,7 +334,7 @@ func TestResolveIncrementalIntent_DoesNotMutateCaller(t *testing.T) { _, err := ResolveIncrementalIntent( ConfigIntent{ - Mode: ModeRPC, + Mode: ModeArchive, Overrides: map[string]string{ "evm.http_port": "9999", }, diff --git a/io.go b/io.go index bfd124e..eb18256 100644 --- a/io.go +++ b/io.go @@ -65,124 +65,27 @@ func WriteConfigToDir(cfg *SeiConfig, homeDir string) error { // Keys use the unified schema paths (e.g. "evm.http_port", "storage.pruning"). // This is the primary mechanism for the sidecar's ConfigApplyTask and the // controller's spec.config.overrides. +// +// Each TOML key is resolved to its Go struct field path via the Registry, then +// set directly through reflection — the same path used by ResolveEnv. func ApplyOverrides(cfg *SeiConfig, overrides map[string]string) error { if len(overrides) == 0 { return nil } - // Encode current config to TOML, decode into generic map, apply overrides, - // then re-decode into SeiConfig. This leverages TOML round-tripping to - // handle type coercion for all field types. - var buf bytes.Buffer - if err := toml.NewEncoder(&buf).Encode(cfg); err != nil { - return fmt.Errorf("encoding config for override application: %w", err) - } - - var m map[string]any - if _, err := toml.NewDecoder(&buf).Decode(&m); err != nil { - return fmt.Errorf("decoding config map: %w", err) - } - + reg := BuildRegistry() for key, val := range overrides { - if err := setNestedKey(m, key, val); err != nil { - return fmt.Errorf("applying override %q=%q: %w", key, val, err) + f := reg.Field(key) + if f == nil { + return fmt.Errorf("unknown override key %q", key) } - } - - // Re-encode the modified map and decode back into SeiConfig - var buf2 bytes.Buffer - if err := toml.NewEncoder(&buf2).Encode(m); err != nil { - return fmt.Errorf("re-encoding after overrides: %w", err) - } - if _, err := toml.NewDecoder(&buf2).Decode(cfg); err != nil { - return fmt.Errorf("decoding overridden config: %w", err) - } - - return nil -} - -// setNestedKey sets a value in a nested map using a dotted key path. -// It attempts to coerce the string value to match the existing value's type. -func setNestedKey(m map[string]any, dottedKey string, value string) error { - parts := splitDottedKey(dottedKey) - if len(parts) == 0 { - return fmt.Errorf("empty key") - } - - current := m - for _, part := range parts[:len(parts)-1] { - next, ok := current[part] - if !ok { - child := make(map[string]any) - current[part] = child - current = child - continue - } - child, ok := next.(map[string]any) - if !ok { - return fmt.Errorf("key %q is not a section", part) + if err := setFieldByPath(cfg, f.FieldPath, val); err != nil { + return fmt.Errorf("applying override %q=%q: %w", key, val, err) } - current = child - } - - finalKey := parts[len(parts)-1] - existing := current[finalKey] - coerced, err := coerceToType(value, existing) - if err != nil { - return fmt.Errorf("coercing value for %q: %w", dottedKey, err) } - current[finalKey] = coerced return nil } -// coerceToType attempts to convert a string value to match the type of an -// existing value. Falls back to string if no existing value or unknown type. -func coerceToType(value string, existing any) (any, error) { - if existing == nil { - return value, nil - } - switch existing.(type) { - case int64: - n, err := parseInt64(value) - return n, err - case float64: - n, err := parseFloat64(value) - return n, err - case bool: - switch value { - case "true", "1", "yes": - return true, nil - case "false", "0", "no": - return false, nil - default: - return nil, fmt.Errorf("invalid bool: %q", value) - } - case string: - return value, nil - default: - return value, nil - } -} - -func splitDottedKey(key string) []string { - var parts []string - current := "" - for _, c := range key { - if c == '.' { - if current != "" { - parts = append(parts, current) - current = "" - } - } else { - current += string(c) - } - } - if current != "" { - parts = append(parts, current) - } - return parts -} - // atomicWriteTOML encodes v as TOML and writes it atomically to path. func atomicWriteTOML(path string, v any) error { var buf bytes.Buffer diff --git a/legacy.go b/legacy.go index ff39e2b..9ded082 100644 --- a/legacy.go +++ b/legacy.go @@ -352,9 +352,9 @@ type legacyGenesis struct { // --------------------------------------------------------------------------- func (cfg *SeiConfig) toLegacyTendermint() legacyTendermintConfig { - // Tendermint treats "archive" as "full" + // Tendermint only understands validator/full/seed; archive maps to full. tmMode := cfg.Mode.String() - if tmMode == "archive" || tmMode == "rpc" || tmMode == "indexer" { + if tmMode == "archive" { tmMode = "full" } diff --git a/resolve.go b/resolve.go index 16ba18d..2b5003b 100644 --- a/resolve.go +++ b/resolve.go @@ -140,23 +140,32 @@ func setReflectValue(v reflect.Value, s string) error { default: return fmt.Errorf("invalid bool value: %q", s) } - case reflect.Int, reflect.Int64: + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: n, err := parseInt64(s) if err != nil { return err } + if v.OverflowInt(n) { + return fmt.Errorf("value %d overflows %s", n, v.Type()) + } v.SetInt(n) - case reflect.Uint, reflect.Uint16, reflect.Uint32, reflect.Uint64: + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: n, err := parseUint64(s) if err != nil { return err } + if v.OverflowUint(n) { + return fmt.Errorf("value %d overflows %s", n, v.Type()) + } v.SetUint(n) - case reflect.Float64: + case reflect.Float32, reflect.Float64: n, err := parseFloat64(s) if err != nil { return err } + if v.OverflowFloat(n) { + return fmt.Errorf("value %g overflows %s", n, v.Type()) + } v.SetFloat(n) default: return fmt.Errorf("unsupported field type: %s", v.Type()) diff --git a/types.go b/types.go index 9fedf3e..d7e7b27 100644 --- a/types.go +++ b/types.go @@ -13,8 +13,6 @@ const ( ModeFull NodeMode = "full" ModeSeed NodeMode = "seed" ModeArchive NodeMode = "archive" - ModeRPC NodeMode = "rpc" - ModeIndexer NodeMode = "indexer" ) var validModes = map[NodeMode]bool{ @@ -22,8 +20,6 @@ var validModes = map[NodeMode]bool{ ModeFull: true, ModeSeed: true, ModeArchive: true, - ModeRPC: true, - ModeIndexer: true, } func (m NodeMode) IsValid() bool { @@ -32,7 +28,7 @@ func (m NodeMode) IsValid() bool { func (m NodeMode) IsFullnodeType() bool { switch m { - case ModeFull, ModeArchive, ModeRPC, ModeIndexer: + case ModeFull, ModeArchive: return true default: return false