Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 190 additions & 1 deletion evmd/cmd/evmd/cmd/testnet.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@
"bufio"
"encoding/json"
"fmt"
"github.com/cosmos/evm/config"
"net"
"os"
"path/filepath"
"reflect"

Check notice

Code scanning / CodeQL

Sensitive package import Note test

Certain system packages contain functions which may be a possible source of non-determinism
"strconv"
"strings"
"time"

"github.com/cosmos/evm/config"

cosmosevmhd "github.com/cosmos/evm/crypto/hd"
cosmosevmkeyring "github.com/cosmos/evm/crypto/keyring"
"github.com/cosmos/evm/evmd"
Expand Down Expand Up @@ -62,6 +66,7 @@
flagPrintMnemonic = "print-mnemonic"
flagSingleHost = "single-host"
flagCommitTimeout = "commit-timeout"
configChanges = "config-changes"
unsafeStartValidatorFn UnsafeStartValidatorCmdCreator
)

Expand All @@ -88,6 +93,7 @@
startingIPAddress string
singleMachine bool
useDocker bool
configChanges []string
}

type startArgs struct {
Expand Down Expand Up @@ -188,6 +194,7 @@
return err
}
args.algo, _ = cmd.Flags().GetString(flags.FlagKeyType)
args.configChanges, _ = cmd.Flags().GetStringSlice(configChanges)

return initTestnetFiles(clientCtx, cmd, config, mbm, genBalIterator, args)
},
Expand All @@ -201,6 +208,7 @@
cmd.Flags().String(flagStartingIPAddress, "192.168.0.1", "Starting IP address (192.168.0.1 results in persistent peers list ID0@192.168.0.1:46656, ID1@192.168.0.2:46656, ...)")
cmd.Flags().String(flags.FlagKeyringBackend, flags.DefaultKeyringBackend, "Select keyring's backend (os|file|test)")
cmd.Flags().Bool(flagsUseDocker, false, "test network via docker")
cmd.Flags().StringSlice(configChanges, []string{}, "Config changes to apply to the node: i.e. consensus.timeout_commit=50s")

return cmd
}
Expand Down Expand Up @@ -245,6 +253,182 @@

const nodeDirPerm = 0o755

// parseAndApplyConfigChanges parses the config changes string and applies them to the nodeConfig
func parseAndApplyConfigChanges(nodeConfig *cmtconfig.Config, configChanges []string) error {
if len(configChanges) == 0 {
return nil
}

if err := ApplyConfigOverrides(nodeConfig, configChanges); err != nil {
return err
}

return nil
}

// UpdateConfigField updates a field in the config based on dot notation
// Example: "consensus.timeout_propose=5s" or "log_level=debug" (for BaseConfig fields)
func UpdateConfigField(config *cmtconfig.Config, fieldPath, value string) error {
parts := strings.Split(fieldPath, ".")

configValue := reflect.ValueOf(config).Elem()

// Handle BaseConfig fields (squashed/embedded fields)
if len(parts) == 1 {
// This might be a BaseConfig field, try to find it in the embedded struct
baseConfigField := configValue.FieldByName("BaseConfig")
if baseConfigField.IsValid() {
targetFieldName := getFieldName(baseConfigField.Type(), parts[0])
if targetFieldName != "" {
targetField := baseConfigField.FieldByName(targetFieldName)
if targetField.IsValid() && targetField.CanSet() {
return setFieldValue(targetField, value)
}
}
}

// If not found in BaseConfig, try in the main Config struct
targetFieldName := getFieldName(configValue.Type(), parts[0])
if targetFieldName != "" {
targetField := configValue.FieldByName(targetFieldName)
if targetField.IsValid() && targetField.CanSet() {
return setFieldValue(targetField, value)
}
}

return fmt.Errorf("field not found: %s", parts[0])
}

// Handle nested fields (e.g., consensus.timeout_propose)
current := configValue
for i, part := range parts[:len(parts)-1] {
field := current.FieldByName(getFieldName(current.Type(), part))
if !field.IsValid() {
return fmt.Errorf("field not found: %s", strings.Join(parts[:i+1], "."))
}

// If it's a pointer to a struct, get the element
if field.Kind() == reflect.Ptr {
if field.IsNil() {
// Initialize the pointer if it's nil
field.Set(reflect.New(field.Type().Elem()))
}
field = field.Elem()
}
current = field
}

// Set the final field
finalFieldName := parts[len(parts)-1]
targetField := current.FieldByName(getFieldName(current.Type(), finalFieldName))
if !targetField.IsValid() {
return fmt.Errorf("field not found: %s", finalFieldName)
}

if !targetField.CanSet() {
return fmt.Errorf("field cannot be set: %s", finalFieldName)
}

return setFieldValue(targetField, value)
}

// getFieldName finds the struct field name from mapstructure tag or field name
func getFieldName(structType reflect.Type, tagName string) string {
for i := 0; i < structType.NumField(); i++ {
field := structType.Field(i)

// Check mapstructure tag
if tag := field.Tag.Get("mapstructure"); tag != "" {
if tag == tagName {
return field.Name
}
}

// Check field name (case insensitive)
if strings.EqualFold(field.Name, tagName) {
return field.Name
}
}
return ""
}

// setFieldValue sets the field value based on its type
func setFieldValue(field reflect.Value, value string) error {
switch field.Type() {
case reflect.TypeOf(time.Duration(0)):
duration, err := time.ParseDuration(value)
if err != nil {
return fmt.Errorf("invalid duration format: %s", value)
}
field.Set(reflect.ValueOf(duration))

case reflect.TypeOf(""):
field.SetString(value)

case reflect.TypeOf(true):
boolVal, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("invalid boolean format: %s", value)
}
field.SetBool(boolVal)

case reflect.TypeOf(int64(0)):
intVal, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return fmt.Errorf("invalid int64 format: %s", value)
}
field.SetInt(intVal)

case reflect.TypeOf(int(0)):
intVal, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("invalid int format: %s", value)
}
field.SetInt(int64(intVal))

case reflect.TypeOf([]string{}):
// Handle string slices - split by comma
var slice []string
if strings.TrimSpace(value) != "" {
// Split by comma and trim whitespace
parts := strings.Split(value, ",")
for _, part := range parts {
slice = append(slice, strings.TrimSpace(part))
}
}
field.Set(reflect.ValueOf(slice))

default:
return fmt.Errorf("unsupported field type: %v", field.Type())
}

return nil
}

// ParseConfigOverride parses a string like "consensus.timeout_propose=5s"
func ParseConfigOverride(override string) (string, string, error) {
parts := strings.SplitN(override, "=", 2)
if len(parts) != 2 {
return "", "", fmt.Errorf("invalid override format: %s (expected field=value)", override)
}
return parts[0], parts[1], nil
}

// ApplyConfigOverrides applies multiple overrides to the config
func ApplyConfigOverrides(config *cmtconfig.Config, overrides []string) error {
for _, override := range overrides {
fieldPath, value, err := ParseConfigOverride(override)
if err != nil {
return err
}

if err := UpdateConfigField(config, fieldPath, value); err != nil {
return fmt.Errorf("failed to set %s: %w", fieldPath, err)
}
}
return nil
}

// initTestnetFiles initializes testnet files for a testnet to be run in a separate process
func initTestnetFiles(
clientCtx client.Context,
Expand Down Expand Up @@ -350,6 +534,11 @@
}
}

if err := parseAndApplyConfigChanges(nodeConfig, args.configChanges); err != nil {
_ = os.RemoveAll(args.outputDir)
return fmt.Errorf("failed to apply config changes for node %d: %w", i, err)
}

nodeIDs[i], valPubKeys[i], err = genutil.InitializeNodeValidatorFiles(nodeConfig)
if err != nil {
_ = os.RemoveAll(args.outputDir)
Expand Down
97 changes: 97 additions & 0 deletions evmd/cmd/evmd/cmd/testnet_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package cmd

import (
"testing"
"time"

cmtconfig "github.com/cometbft/cometbft/config"
"github.com/stretchr/testify/require"
)

func TestParseAndApplyConfigChanges(t *testing.T) {
tests := []struct {
name string
override string
expectedValue interface{}
checkFunc func(*cmtconfig.Config) interface{}
}{
{
name: "consensus timeout_propose",
override: "consensus.timeout_propose=10s",
expectedValue: 10 * time.Second,
checkFunc: func(cfg *cmtconfig.Config) interface{} { return cfg.Consensus.TimeoutPropose },
},
{
name: "consensus create_empty_blocks",
override: "consensus.create_empty_blocks=false",
expectedValue: false,
checkFunc: func(cfg *cmtconfig.Config) interface{} { return cfg.Consensus.CreateEmptyBlocks },
},
{
name: "consensus double_sign_check_height",
override: "consensus.double_sign_check_height=500",
expectedValue: int64(500),
checkFunc: func(cfg *cmtconfig.Config) interface{} { return cfg.Consensus.DoubleSignCheckHeight },
},
{
name: "baseconfig home",
override: "home=/custom/path",
expectedValue: "/custom/path",
checkFunc: func(cfg *cmtconfig.Config) interface{} { return cfg.RootDir },
},
{
name: "baseconfig log_level",
override: "log_level=error",
expectedValue: "error",
checkFunc: func(cfg *cmtconfig.Config) interface{} { return cfg.LogLevel },
},
{
name: "baseconfig log_format",
override: "log_format=json",
expectedValue: "json",
checkFunc: func(cfg *cmtconfig.Config) interface{} { return cfg.LogFormat },
},
{
name: "baseconfig db_backend",
override: "db_backend=badgerdb",
expectedValue: "badgerdb",
checkFunc: func(cfg *cmtconfig.Config) interface{} { return cfg.DBBackend },
},
{
name: "string slice single value",
override: "statesync.rpc_servers=production",
expectedValue: []string{"production"},
checkFunc: func(cfg *cmtconfig.Config) interface{} {
return cfg.StateSync.RPCServers
},
},
{
name: "string slice multiple values",
override: "statesync.rpc_servers=production,monitoring,critical",
expectedValue: []string{"production", "monitoring", "critical"},
checkFunc: func(cfg *cmtconfig.Config) interface{} {
return cfg.StateSync.RPCServers
},
},
{
name: "string slice empty",
override: "statesync.rpc_servers=",
expectedValue: []string(nil),
checkFunc: func(cfg *cmtconfig.Config) interface{} {
return cfg.StateSync.RPCServers
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := cmtconfig.DefaultConfig()

err := parseAndApplyConfigChanges(cfg, []string{tt.override})
require.NoError(t, err)

actualValue := tt.checkFunc(cfg)
require.Equal(t, tt.expectedValue, actualValue)
})
}
}
Loading
Loading