diff --git a/CHANGELOG.md b/CHANGELOG.md index 4958458cebd2..f8b5b452b08c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,8 @@ Every module contains its own CHANGELOG.md. Please refer to the module you are i ### API Breaking Changes +* (client) [#22775](https://github.com/cosmos/cosmos-sdk/pull/22775) Removed client prompt validations. + ### Deprecated ## [v0.52.0](https://github.com/cosmos/cosmos-sdk/releases/tag/v0.52.0) - 2024-XX-XX diff --git a/client/prompt_validation_test.go b/client/prompt_validation_test.go deleted file mode 100644 index 488aa03e5414..000000000000 --- a/client/prompt_validation_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package client_test - -import ( - "testing" - - "github.com/stretchr/testify/require" - - "github.com/cosmos/cosmos-sdk/client" -) - -func TestValidatePromptNotEmpty(t *testing.T) { - require := require.New(t) - - require.NoError(client.ValidatePromptNotEmpty("foo")) - require.ErrorContains(client.ValidatePromptNotEmpty(""), "input cannot be empty") -} - -func TestValidatePromptURL(t *testing.T) { - require := require.New(t) - - require.NoError(client.ValidatePromptURL("https://example.com")) - require.ErrorContains(client.ValidatePromptURL("foo"), "invalid URL") -} - -func TestValidatePromptAddress(t *testing.T) { - require := require.New(t) - - require.NoError(client.ValidatePromptAddress("cosmos1huydeevpz37sd9snkgul6070mstupukw00xkw9")) - require.NoError(client.ValidatePromptAddress("cosmosvaloper1sjllsnramtg3ewxqwwrwjxfgc4n4ef9u2lcnj0")) - require.NoError(client.ValidatePromptAddress("cosmosvalcons1ntk8eualewuprz0gamh8hnvcem2nrcdsgz563h")) - require.ErrorContains(client.ValidatePromptAddress("foo"), "invalid address") -} - -func TestValidatePromptCoins(t *testing.T) { - require := require.New(t) - - require.NoError(client.ValidatePromptCoins("100stake")) - require.ErrorContains(client.ValidatePromptCoins("foo"), "invalid coins") -} diff --git a/client/v2/CHANGELOG.md b/client/v2/CHANGELOG.md index 33052c9000e7..e4011ec3750e 100644 --- a/client/v2/CHANGELOG.md +++ b/client/v2/CHANGELOG.md @@ -45,6 +45,7 @@ Ref: https://keepachangelog.com/en/1.0.0/ * [#20623](https://github.com/cosmos/cosmos-sdk/pull/20623) Introduce client/v2 tx factory. * [#20623](https://github.com/cosmos/cosmos-sdk/pull/20623) Extend client/v2 keyring interface with `KeyType` and `KeyInfo`. * [#22282](https://github.com/cosmos/cosmos-sdk/pull/22282) Added custom broadcast logic. +* [#22775](https://github.com/cosmos/cosmos-sdk/pull/22775) Added interactive autocli prompt functionality, including message field prompting, validation helpers, and default value support. ### Improvements diff --git a/client/v2/autocli/prompt/message.go b/client/v2/autocli/prompt/message.go new file mode 100644 index 000000000000..c6e3738b8668 --- /dev/null +++ b/client/v2/autocli/prompt/message.go @@ -0,0 +1,259 @@ +package prompt + +import ( + "fmt" + "io" + "strconv" + "strings" + + "github.com/manifoldco/promptui" + "google.golang.org/protobuf/reflect/protoreflect" + + "cosmossdk.io/client/v2/autocli/flag" + addresscodec "cosmossdk.io/core/address" +) + +// PromptMessage prompts the user for values to populate a protobuf message interactively. +// It returns the populated message and any error encountered during prompting. +func PromptMessage( + addressCodec, validatorAddressCodec, consensusAddressCodec addresscodec.Codec, + promptPrefix string, msg protoreflect.Message, +) (protoreflect.Message, error) { + return promptMessage(addressCodec, validatorAddressCodec, consensusAddressCodec, promptPrefix, nil, msg) +} + +// promptMessage prompts the user for values to populate a protobuf message interactively. +// stdIn is provided to make the function easier to unit test by allowing injection of predefined inputs. +func promptMessage( + addressCodec, validatorAddressCodec, consensusAddressCodec addresscodec.Codec, + promptPrefix string, stdIn io.ReadCloser, msg protoreflect.Message, +) (protoreflect.Message, error) { + fields := msg.Descriptor().Fields() + for i := 0; i < fields.Len(); i++ { + field := fields.Get(i) + fieldName := string(field.Name()) + + promptUi := promptui.Prompt{ + Validate: ValidatePromptNotEmpty, + Stdin: stdIn, + } + + // If this signer field has already a valid default value set, + // use that value as the default prompt value. This is useful for + // commands that have an authority such as gov. + if strings.EqualFold(fieldName, flag.GetSignerFieldName(msg.Descriptor())) { + if defaultValue := msg.Get(field); defaultValue.IsValid() { + promptUi.Default = defaultValue.String() + } + } + + // validate address fields + scalarField, ok := flag.GetScalarType(field) + if ok { + switch scalarField { + case flag.AddressStringScalarType: + promptUi.Validate = ValidateAddress(addressCodec) + case flag.ValidatorAddressStringScalarType: + promptUi.Validate = ValidateAddress(validatorAddressCodec) + case flag.ConsensusAddressStringScalarType: + promptUi.Validate = ValidateAddress(consensusAddressCodec) + default: + // prompt.Validate = ValidatePromptNotEmpty (we possibly don't want to force all fields to be non-empty) + promptUi.Validate = nil + } + } + + // handle nested message fields recursively + if field.Kind() == protoreflect.MessageKind { + err := promptInnerMessageKind(field, addressCodec, validatorAddressCodec, consensusAddressCodec, promptPrefix, stdIn, msg) + if err != nil { + return nil, err + } + continue + } + + // handle repeated fields by prompting for a comma-separated list of values + if field.IsList() { + list, err := promptList(field, msg, promptUi, promptPrefix) + if err != nil { + return nil, err + } + + msg.Set(field, protoreflect.ValueOfList(list)) + continue + } + + promptUi.Label = fmt.Sprintf("Enter %s %s", promptPrefix, fieldName) + result, err := promptUi.Run() + if err != nil { + return msg, fmt.Errorf("failed to prompt for %s: %w", fieldName, err) + } + + v, err := valueOf(field, result) + if err != nil { + return msg, err + } + msg.Set(field, v) + } + + return msg, nil +} + +// valueOf converts a string input value to a protoreflect.Value based on the field's type. +// It handles string, numeric, bool, bytes and enum field types. +// Returns the converted value and any error that occurred during conversion. +func valueOf(field protoreflect.FieldDescriptor, result string) (protoreflect.Value, error) { + switch field.Kind() { + case protoreflect.StringKind: + return protoreflect.ValueOfString(result), nil + case protoreflect.Uint32Kind, protoreflect.Fixed32Kind, protoreflect.Uint64Kind, protoreflect.Fixed64Kind: + resultUint, err := strconv.ParseUint(result, 10, 0) + if err != nil { + return protoreflect.Value{}, fmt.Errorf("invalid value for int: %w", err) + } + + return protoreflect.ValueOfUint64(resultUint), nil + case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind, protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Sfixed64Kind: + resultInt, err := strconv.ParseInt(result, 10, 0) + if err != nil { + return protoreflect.Value{}, fmt.Errorf("invalid value for int: %w", err) + } + // If a value was successfully parsed the ranges of: + // [minInt, maxInt] + // are within the ranges of: + // [minInt64, maxInt64] + // of which on 64-bit machines, which are most common, + // int==int64 + return protoreflect.ValueOfInt64(resultInt), nil + case protoreflect.BoolKind: + resultBool, err := strconv.ParseBool(result) + if err != nil { + return protoreflect.Value{}, fmt.Errorf("invalid value for bool: %w", err) + } + + return protoreflect.ValueOfBool(resultBool), nil + case protoreflect.BytesKind: + resultBytes := []byte(result) + return protoreflect.ValueOfBytes(resultBytes), nil + case protoreflect.EnumKind: + enumValue := field.Enum().Values().ByName(protoreflect.Name(result)) + if enumValue == nil { + return protoreflect.Value{}, fmt.Errorf("invalid enum value %q", result) + } + return protoreflect.ValueOfEnum(enumValue.Number()), nil + default: + // TODO: add more kinds + // skip any other types + return protoreflect.Value{}, nil + } +} + +// promptList prompts the user for a comma-separated list of values for a repeated field. +// The user will be prompted to enter values separated by commas which will be parsed +// according to the field's type using valueOf. +func promptList(field protoreflect.FieldDescriptor, msg protoreflect.Message, promptUi promptui.Prompt, promptPrefix string) (protoreflect.List, error) { + promptUi.Label = fmt.Sprintf("Enter %s %s list (separate values with ',')", promptPrefix, string(field.Name())) + result, err := promptUi.Run() + if err != nil { + return nil, fmt.Errorf("failed to prompt for %s: %w", string(field.Name()), err) + } + + list := msg.Mutable(field).List() + for _, item := range strings.Split(result, ",") { + v, err := valueOf(field, item) + if err != nil { + return nil, err + } + list.Append(v) + } + + return list, nil +} + +// promptInnerMessageKind handles prompting for fields that are of message kind. +// It handles both single messages and repeated message fields by delegating to +// promptInnerMessage and promptMessageList respectively. +func promptInnerMessageKind( + f protoreflect.FieldDescriptor, addressCodec addresscodec.Codec, + validatorAddressCodec, consensusAddressCodec addresscodec.Codec, + promptPrefix string, stdIn io.ReadCloser, msg protoreflect.Message, +) error { + if f.IsList() { + return promptMessageList(f, addressCodec, validatorAddressCodec, consensusAddressCodec, promptPrefix, stdIn, msg) + } + return promptInnerMessage(f, addressCodec, validatorAddressCodec, consensusAddressCodec, promptPrefix, stdIn, msg) +} + +// promptInnerMessage prompts for a single nested message field. It creates a new message instance, +// recursively prompts for its fields, and sets the populated message on the parent message. +func promptInnerMessage( + f protoreflect.FieldDescriptor, addressCodec addresscodec.Codec, + validatorAddressCodec, consensusAddressCodec addresscodec.Codec, + promptPrefix string, stdIn io.ReadCloser, msg protoreflect.Message, +) error { + fieldName := promptPrefix + "." + string(f.Name()) + nestedMsg := msg.Get(f).Message() + nestedMsg = nestedMsg.New() + // Recursively prompt for nested message fields + updatedMsg, err := promptMessage( + addressCodec, + validatorAddressCodec, + consensusAddressCodec, + fieldName, + stdIn, + nestedMsg, + ) + if err != nil { + return fmt.Errorf("failed to prompt for nested message %s: %w", fieldName, err) + } + + msg.Set(f, protoreflect.ValueOfMessage(updatedMsg)) + return nil +} + +// promptMessageList prompts for a repeated message field by repeatedly creating new message instances, +// prompting for their fields, and appending them to the list until the user chooses to stop. +func promptMessageList( + f protoreflect.FieldDescriptor, addressCodec addresscodec.Codec, + validatorAddressCodec, consensusAddressCodec addresscodec.Codec, + promptPrefix string, stdIn io.ReadCloser, msg protoreflect.Message, +) error { + list := msg.Mutable(f).List() + for { + fieldName := promptPrefix + "." + string(f.Name()) + // Create and populate a new message for the list + nestedMsg := list.NewElement().Message() + updatedMsg, err := promptMessage( + addressCodec, + validatorAddressCodec, + consensusAddressCodec, + fieldName, + stdIn, + nestedMsg, + ) + if err != nil { + return fmt.Errorf("failed to prompt for list item in %s: %w", fieldName, err) + } + + list.Append(protoreflect.ValueOfMessage(updatedMsg)) + + // Prompt whether to continue + // TODO: may be better yes/no rather than interactive? + continuePrompt := promptui.Select{ + Label: "Add another item?", + Items: []string{"No", "Yes"}, + Stdin: stdIn, + } + + _, result, err := continuePrompt.Run() + if err != nil { + return fmt.Errorf("failed to prompt for continuation: %w", err) + } + + if result == "No" { + break + } + } + + return nil +} diff --git a/client/v2/autocli/prompt/message_test.go b/client/v2/autocli/prompt/message_test.go new file mode 100644 index 000000000000..353cbf6cd725 --- /dev/null +++ b/client/v2/autocli/prompt/message_test.go @@ -0,0 +1,59 @@ +package prompt + +import ( + "io" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/reflect/protoreflect" + + "cosmossdk.io/client/v2/internal/testpb" + + address2 "github.com/cosmos/cosmos-sdk/codec/address" +) + +func getReader(inputs []string) io.ReadCloser { + // https://github.com/manifoldco/promptui/issues/63#issuecomment-621118463 + var paddedInputs []string + for _, input := range inputs { + padding := strings.Repeat("a", 4096-1-len(input)%4096) + paddedInputs = append(paddedInputs, input+"\n"+padding) + } + return io.NopCloser(strings.NewReader(strings.Join(paddedInputs, ""))) +} + +func TestPromptMessage(t *testing.T) { + tests := []struct { + name string + msg protoreflect.Message + inputs []string + }{ + { + name: "testPb", + inputs: []string{ + "1", "2", "string", "bytes", "10101010", "0", "234234", "3", "4", "5", "true", "ENUM_ONE", + "bar", "6", "10000", "stake", "cosmos10d07y265gmmuvt4z0w9aw880jnsr700j6zn9kn", + "bytes", "6", "7", "false", "false", "true,false,true", "1,2,3", "hello,hola,ciao", "ENUM_ONE,ENUM_TWO", + "10239", "0", "No", "bar", "343", "No", "134", "positional2", "23455", "stake", "No", "deprecate", + "shorthand", "false", "cosmosvaloper1tnh2q55v8wyygtt9srz5safamzdengsn9dsd7z", + }, + msg: (&testpb.MsgRequest{}).ProtoReflect(), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // https://github.com/manifoldco/promptui/issues/63#issuecomment-621118463 + var paddedInputs []string + for _, input := range tt.inputs { + padding := strings.Repeat("a", 4096-1-len(input)%4096) + paddedInputs = append(paddedInputs, input+"\n"+padding) + } + reader := io.NopCloser(strings.NewReader(strings.Join(paddedInputs, ""))) + + got, err := promptMessage(address2.NewBech32Codec("cosmos"), address2.NewBech32Codec("cosmosvaloper"), address2.NewBech32Codec("cosmosvalcons"), "prefix", reader, tt.msg) + require.NoError(t, err) + require.NotNil(t, got) + }) + } +} diff --git a/client/v2/autocli/prompt/struct.go b/client/v2/autocli/prompt/struct.go new file mode 100644 index 000000000000..a450b5a60e22 --- /dev/null +++ b/client/v2/autocli/prompt/struct.go @@ -0,0 +1,134 @@ +package prompt + +import ( + "fmt" + "io" + "reflect" + "strconv" + "strings" + + "github.com/manifoldco/promptui" +) + +// PromptStruct prompts for values of a struct's fields interactively. +// It returns the populated struct and any error encountered. +func PromptStruct[T any](promptPrefix string, data T) (T, error) { + return promptStruct(promptPrefix, data, nil) +} + +// promptStruct prompts for values of a struct's fields interactively. +// +// For each field in the struct: +// - Pointer fields are initialized if nil and handled recursively if they contain structs +// - Struct fields are handled recursively +// - String and int slices are supported +// - String and int fields are prompted for and populated +// - Only String and int pointers are supported +// - Other types are skipped +func promptStruct[T any](promptPrefix string, data T, stdIn io.ReadCloser) (T, error) { + v := reflect.ValueOf(&data).Elem() + if v.Kind() == reflect.Interface { + v = reflect.ValueOf(data) + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + } + + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + fieldName := strings.ToLower(v.Type().Field(i).Name) + + // Handle pointer types + if field.Kind() == reflect.Ptr { + if field.IsNil() { + field.Set(reflect.New(field.Type().Elem())) + } + if field.Elem().Kind() == reflect.Struct { + result, err := promptStruct(promptPrefix+"."+fieldName, field.Interface(), stdIn) + if err != nil { + return data, err + } + field.Set(reflect.ValueOf(result)) + continue + } + } + + switch field.Kind() { + case reflect.Struct: + // For struct fields, create a new pointer to handle them + structPtr := reflect.New(field.Type()).Interface() + reflect.ValueOf(structPtr).Elem().Set(field) + + result, err := promptStruct(promptPrefix+"."+fieldName, structPtr, stdIn) + if err != nil { + return data, err + } + + // Get the actual struct value from the result + resultValue := reflect.ValueOf(result) + if resultValue.Kind() == reflect.Ptr { + resultValue = resultValue.Elem() + } + field.Set(resultValue) + continue + case reflect.Slice: + if v.Field(i).Type().Elem().Kind() != reflect.String && v.Field(i).Type().Elem().Kind() != reflect.Int { + continue + } + } + + // create prompts + prompt := promptui.Prompt{ + Label: fmt.Sprintf("Enter %s %s", promptPrefix, strings.Title(fieldName)), // nolint:staticcheck // strings.Title has a better API + Validate: ValidatePromptNotEmpty, + Stdin: stdIn, + } + + result, err := prompt.Run() + if err != nil { + return data, fmt.Errorf("failed to prompt for %s: %w", fieldName, err) + } + + switch field.Kind() { + case reflect.String: + v.Field(i).SetString(result) + case reflect.Int: + resultInt, err := strconv.ParseInt(result, 10, 0) + if err != nil { + return data, fmt.Errorf("invalid value for int: %w", err) + } + v.Field(i).SetInt(resultInt) + case reflect.Slice: + switch v.Field(i).Type().Elem().Kind() { + case reflect.String: + v.Field(i).Set(reflect.ValueOf([]string{result})) + case reflect.Int: + resultInt, err := strconv.ParseInt(result, 10, 0) + if err != nil { + return data, fmt.Errorf("invalid value for int: %w", err) + } + + v.Field(i).Set(reflect.ValueOf([]int{int(resultInt)})) + } + case reflect.Ptr: + // Handle pointer fields by creating a new value and setting it + ptrValue := reflect.New(field.Type().Elem()) + if ptrValue.Elem().Kind() == reflect.String { + ptrValue.Elem().SetString(result) + v.Field(i).Set(ptrValue) + } else if ptrValue.Elem().Kind() == reflect.Int { + resultInt, err := strconv.ParseInt(result, 10, 0) + if err != nil { + return data, fmt.Errorf("invalid value for int: %w", err) + } + ptrValue.Elem().SetInt(resultInt) + v.Field(i).Set(ptrValue) + } + default: + // skip any other types + continue + } + } + + return data, nil +} diff --git a/client/v2/autocli/prompt/struct_test.go b/client/v2/autocli/prompt/struct_test.go new file mode 100644 index 000000000000..1b712d81a0e7 --- /dev/null +++ b/client/v2/autocli/prompt/struct_test.go @@ -0,0 +1,46 @@ +package prompt + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +type innerStruct struct { + A string + B int +} + +type testStruct struct { + A string + B int + C *innerStruct + D innerStruct + E *string + F []string +} + +func TestPromptStruct(t *testing.T) { + type testCase[T any] struct { + name string + data T + inputs []string + } + tests := []testCase[testStruct]{ + { + name: "test struct", + data: testStruct{}, + inputs: []string{ + "a", "1", "b", "2", "c", "3", "pointerStr", "list", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inputs := getReader(tt.inputs) + got, err := promptStruct("testStruct", tt.data, inputs) + require.NoError(t, err) + require.NotNil(t, got) + }) + } +} diff --git a/client/v2/autocli/prompt/util.go b/client/v2/autocli/prompt/util.go new file mode 100644 index 000000000000..4b3bdec56baf --- /dev/null +++ b/client/v2/autocli/prompt/util.go @@ -0,0 +1,70 @@ +package prompt + +import ( + "fmt" + + "github.com/manifoldco/promptui" + "google.golang.org/protobuf/reflect/protoreflect" +) + +// Select prompts the user to select an option from a list of choices. +// It takes a label string to display above the selection prompt and a slice of string options to choose from. +func Select(label string, options []string) (string, error) { + selectUi := promptui.Select{ + Label: label, + Items: options, + } + + _, selectedProposalType, err := selectUi.Run() + if err != nil { + return "", fmt.Errorf("failed to prompt proposal types: %w", err) + } + + return selectedProposalType, nil +} + +// PromptString prompts the user for a string input with the given label. +// It validates the input using the provided validate function. +func PromptString(label string, validate func(string) error) (string, error) { + promptUi := promptui.Prompt{ + Label: label, + Validate: validate, + } + + return promptUi.Run() +} + +// SetDefaults sets default values on a protobuf message based on a map of field names to values. +// It iterates through the message fields and sets values from the defaults map if the field name +// and type match. +func SetDefaults(msg protoreflect.Message, defaults map[string]interface{}) { + fields := msg.Descriptor().Fields() + for i := 0; i < fields.Len(); i++ { + field := fields.Get(i) + fieldName := string(field.Name()) + + if v, ok := defaults[fieldName]; ok { + // Get the field's kind + fieldKind := field.Kind() + + switch v.(type) { + case string: + if fieldKind == protoreflect.StringKind { + msg.Set(field, protoreflect.ValueOf(v)) + } + case int64: + if fieldKind == protoreflect.Int64Kind { + msg.Set(field, protoreflect.ValueOf(v)) + } + case int32: + if fieldKind == protoreflect.Int32Kind { + msg.Set(field, protoreflect.ValueOf(v)) + } + case bool: + if fieldKind == protoreflect.BoolKind { + msg.Set(field, protoreflect.ValueOf(v)) + } + } + } + } +} diff --git a/client/prompt_validation.go b/client/v2/autocli/prompt/validation.go similarity index 51% rename from client/prompt_validation.go rename to client/v2/autocli/prompt/validation.go index 9d3af0d58f67..c189918434ab 100644 --- a/client/prompt_validation.go +++ b/client/v2/autocli/prompt/validation.go @@ -1,4 +1,4 @@ -package client +package prompt import ( "errors" @@ -6,7 +6,7 @@ import ( "net/url" "unicode" - sdk "github.com/cosmos/cosmos-sdk/types" + "cosmossdk.io/core/address" ) // ValidatePromptNotEmpty validates that the input is not empty. @@ -18,40 +18,23 @@ func ValidatePromptNotEmpty(input string) error { return nil } -// ValidatePromptURL validates that the input is a valid URL. -func ValidatePromptURL(input string) error { - _, err := url.ParseRequestURI(input) - if err != nil { - return fmt.Errorf("invalid URL: %w", err) - } - - return nil -} - -// ValidatePromptAddress validates that the input is a valid Bech32 address. -func ValidatePromptAddress(input string) error { // TODO(@julienrbrt) remove and add prompts in AutoCLI - _, err := sdk.AccAddressFromBech32(input) - if err == nil { - return nil - } - - _, err = sdk.ValAddressFromBech32(input) - if err == nil { - return nil - } +// ValidateAddress returns a validation function that checks if a string is a valid address +// for the given address codec. +func ValidateAddress(ac address.Codec) func(string) error { + return func(i string) error { + if _, err := ac.StringToBytes(i); err != nil { + return fmt.Errorf("invalid address") + } - _, err = sdk.ConsAddressFromBech32(input) - if err == nil { return nil } - - return fmt.Errorf("invalid address: %w", err) } -// ValidatePromptCoins validates that the input contains valid sdk.Coins -func ValidatePromptCoins(input string) error { - if _, err := sdk.ParseCoinsNormalized(input); err != nil { - return fmt.Errorf("invalid coins: %w", err) +// ValidatePromptURL validates that the input is a valid URL. +func ValidatePromptURL(input string) error { + _, err := url.ParseRequestURI(input) + if err != nil { + return fmt.Errorf("invalid URL: %w", err) } return nil diff --git a/client/v2/autocli/prompt/validation_test.go b/client/v2/autocli/prompt/validation_test.go new file mode 100644 index 000000000000..32b65b2c5b26 --- /dev/null +++ b/client/v2/autocli/prompt/validation_test.go @@ -0,0 +1,55 @@ +package prompt + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "cosmossdk.io/core/address" + + address2 "github.com/cosmos/cosmos-sdk/codec/address" +) + +func TestValidatePromptNotEmpty(t *testing.T) { + require := require.New(t) + + require.NoError(ValidatePromptNotEmpty("foo")) + require.ErrorContains(ValidatePromptNotEmpty(""), "input cannot be empty") +} + +func TestValidateAddress(t *testing.T) { + tests := []struct { + name string + ac address.Codec + addr string + }{ + { + name: "address", + ac: address2.NewBech32Codec("cosmos"), + addr: "cosmos129lxcu2n3hx54fdxlwsahqkjr3sp32cxm00zlm", + }, + { + name: "validator address", + ac: address2.NewBech32Codec("cosmosvaloper"), + addr: "cosmosvaloper1tnh2q55v8wyygtt9srz5safamzdengsn9dsd7z", + }, + { + name: "consensus address", + ac: address2.NewBech32Codec("cosmosvalcons"), + addr: "cosmosvalcons136uu5rj23kdr3jjcmjt7aw5qpugjjat2klgrus", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateAddress(tt.ac)(tt.addr) + require.NoError(t, err) + }) + } +} + +func TestValidatePromptURL(t *testing.T) { + require := require.New(t) + + require.NoError(ValidatePromptURL("https://example.com")) + require.ErrorContains(ValidatePromptURL("foo"), "invalid URL") +} diff --git a/client/v2/go.mod b/client/v2/go.mod index a2daf5013614..825387886272 100644 --- a/client/v2/go.mod +++ b/client/v2/go.mod @@ -118,7 +118,7 @@ require ( github.com/lib/pq v1.10.9 // indirect github.com/linxGnu/grocksdb v1.9.3 // indirect github.com/magiconair/properties v1.8.9 // indirect - github.com/manifoldco/promptui v0.9.0 // indirect + github.com/manifoldco/promptui v0.9.0 github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/minio/highwayhash v1.0.3 // indirect diff --git a/client/v2/internal/prompt/validation.go b/client/v2/internal/prompt/validation.go deleted file mode 100644 index d914999f214d..000000000000 --- a/client/v2/internal/prompt/validation.go +++ /dev/null @@ -1,37 +0,0 @@ -package prompt - -import ( - "errors" - "fmt" - "net/url" - - sdk "github.com/cosmos/cosmos-sdk/types" -) - -// ValidatePromptNotEmpty validates that the input is not empty. -func ValidatePromptNotEmpty(input string) error { - if input == "" { - return errors.New("input cannot be empty") - } - - return nil -} - -// ValidatePromptURL validates that the input is a valid URL. -func ValidatePromptURL(input string) error { - _, err := url.ParseRequestURI(input) - if err != nil { - return fmt.Errorf("invalid URL: %w", err) - } - - return nil -} - -// ValidatePromptCoins validates that the input contains valid sdk.Coins -func ValidatePromptCoins(input string) error { - if _, err := sdk.ParseCoinsNormalized(input); err != nil { - return fmt.Errorf("invalid coins: %w", err) - } - - return nil -} diff --git a/client/v2/internal/prompt/validation_test.go b/client/v2/internal/prompt/validation_test.go deleted file mode 100644 index 86e4ba4ab475..000000000000 --- a/client/v2/internal/prompt/validation_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package prompt_test - -import ( - "testing" - - "github.com/stretchr/testify/require" - - "cosmossdk.io/client/v2/internal/prompt" -) - -func TestValidatePromptNotEmpty(t *testing.T) { - require := require.New(t) - - require.NoError(prompt.ValidatePromptNotEmpty("foo")) - require.ErrorContains(prompt.ValidatePromptNotEmpty(""), "input cannot be empty") -} - -func TestValidatePromptURL(t *testing.T) { - require := require.New(t) - - require.NoError(prompt.ValidatePromptURL("https://example.com")) - require.ErrorContains(prompt.ValidatePromptURL("foo"), "invalid URL") -} - -func TestValidatePromptCoins(t *testing.T) { - require := require.New(t) - - require.NoError(prompt.ValidatePromptCoins("100stake")) - require.ErrorContains(prompt.ValidatePromptCoins("foo"), "invalid coins") -} diff --git a/simapp/go.mod b/simapp/go.mod index 368fb7f27b40..834a72990b14 100644 --- a/simapp/go.mod +++ b/simapp/go.mod @@ -4,7 +4,7 @@ go 1.23.3 require ( cosmossdk.io/api v0.7.6 - cosmossdk.io/client/v2 v2.0.0-20230630094428-02b760776860 + cosmossdk.io/client/v2 v2.0.0-beta.6 cosmossdk.io/collections v0.4.1-0.20241128094659-bd76b47e1d8b cosmossdk.io/core v1.0.0-alpha.6 cosmossdk.io/core/testing v0.0.0-20241108153815-606544c7be7e diff --git a/simapp/v2/go.mod b/simapp/v2/go.mod index e3ce2f17bafc..48cc9a8c688c 100644 --- a/simapp/v2/go.mod +++ b/simapp/v2/go.mod @@ -4,7 +4,7 @@ go 1.23.3 require ( cosmossdk.io/api v0.7.6 - cosmossdk.io/client/v2 v2.0.0-00010101000000-000000000000 + cosmossdk.io/client/v2 v2.0.0-beta.6 cosmossdk.io/core v1.0.0-alpha.6 cosmossdk.io/depinject v1.1.0 cosmossdk.io/log v1.5.0 diff --git a/tests/go.mod b/tests/go.mod index fb76f7776604..edb0679af957 100644 --- a/tests/go.mod +++ b/tests/go.mod @@ -68,7 +68,7 @@ require ( cloud.google.com/go/compute/metadata v0.5.0 // indirect cloud.google.com/go/iam v1.1.13 // indirect cloud.google.com/go/storage v1.43.0 // indirect - cosmossdk.io/client/v2 v2.0.0-20230630094428-02b760776860 // indirect + cosmossdk.io/client/v2 v2.0.0-beta.6 // indirect cosmossdk.io/errors v1.0.1 // indirect cosmossdk.io/errors/v2 v2.0.0-20240731132947-df72853b3ca5 // indirect cosmossdk.io/indexer/postgres v0.1.0 // indirect diff --git a/x/feegrant/go.mod b/x/feegrant/go.mod index faead6769e13..c80b0ec793a9 100644 --- a/x/feegrant/go.mod +++ b/x/feegrant/go.mod @@ -12,7 +12,7 @@ require ( cosmossdk.io/math v1.4.0 cosmossdk.io/store v1.1.1-0.20240418092142-896cdf1971bc cosmossdk.io/x/bank v0.0.0-20240226161501-23359a0b6d91 - cosmossdk.io/x/gov v0.0.0-20230925135524-a1bc045b3190 + cosmossdk.io/x/gov v0.0.0-20231113122742-912390d5fc4a github.com/cometbft/cometbft v1.0.0-rc2.0.20241127125717-4ce33b646ac9 // indirect github.com/cosmos/cosmos-proto v1.0.0-beta.5 github.com/cosmos/cosmos-sdk v0.53.0 @@ -36,6 +36,7 @@ require ( require ( buf.build/gen/go/cometbft/cometbft/protocolbuffers/go v1.35.2-20241120201313-68e42a58b301.1 // indirect buf.build/gen/go/cosmos/gogo-proto/protocolbuffers/go v1.35.2-20240130113600-88ef6483f90f.1 // indirect + cosmossdk.io/client/v2 v2.0.0-beta.6 // indirect cosmossdk.io/log v1.5.0 // indirect cosmossdk.io/schema v0.4.0 // indirect cosmossdk.io/x/protocolpool v0.0.0-20230925135524-a1bc045b3190 // indirect @@ -182,6 +183,7 @@ replace github.com/cosmos/cosmos-sdk => ../../. // TODO remove post spinning out all modules replace ( cosmossdk.io/api => ../../api + cosmossdk.io/client/v2 => ../../client/v2 cosmossdk.io/store => ../../store cosmossdk.io/x/bank => ../bank cosmossdk.io/x/gov => ../gov diff --git a/x/gov/CHANGELOG.md b/x/gov/CHANGELOG.md index 231a520171a9..4743c45d6571 100644 --- a/x/gov/CHANGELOG.md +++ b/x/gov/CHANGELOG.md @@ -61,6 +61,7 @@ Ref: https://keepachangelog.com/en/1.0.0/ ### Client Breaking Changes * [#19101](https://github.com/cosmos/cosmos-sdk/pull/19101) Querying specific params types was deprecated in gov/v1 and has been removed. gov/v1beta1 rest unchanged. +* [#22775](https://github.com/cosmos/cosmos-sdk/pull/22775) Refactored interactive proposal prompts to use `client/v2/autocli/prompt` package. ### API Breaking Changes diff --git a/x/gov/client/cli/prompt.go b/x/gov/client/cli/prompt.go index ec5f49287711..08a4c1d05d77 100644 --- a/x/gov/client/cli/prompt.go +++ b/x/gov/client/cli/prompt.go @@ -4,14 +4,15 @@ import ( "encoding/json" "fmt" "os" - "reflect" // #nosec "sort" - "strconv" "strings" - "github.com/manifoldco/promptui" + gogoproto "github.com/cosmos/gogoproto/proto" "github.com/spf13/cobra" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoregistry" + "cosmossdk.io/client/v2/autocli/prompt" "cosmossdk.io/core/address" "cosmossdk.io/x/gov/types" @@ -19,7 +20,7 @@ import ( "github.com/cosmos/cosmos-sdk/client/flags" "github.com/cosmos/cosmos-sdk/codec" sdk "github.com/cosmos/cosmos-sdk/types" - authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" + sdkaddress "github.com/cosmos/cosmos-sdk/types/address" ) const ( @@ -60,102 +61,6 @@ var suggestedProposalTypes = []proposalType{ }, } -// Prompt prompts the user for all values of the given type. -// data is the struct to be filled -// namePrefix is the name to be displayed as "Enter " -// TODO: when bringing this in autocli, use proto message instead -// this will simplify the get address logic -func Prompt[T any](data T, namePrefix string, addressCodec address.Codec) (T, error) { - v := reflect.ValueOf(&data).Elem() - if v.Kind() == reflect.Interface { - v = reflect.ValueOf(data) - if v.Kind() == reflect.Ptr { - v = v.Elem() - } - } - - for i := 0; i < v.NumField(); i++ { - // if the field is a struct skip or not slice of string or int then skip - switch v.Field(i).Kind() { - case reflect.Struct: - // TODO(@julienrbrt) in the future we can add a recursive call to Prompt - continue - case reflect.Slice: - if v.Field(i).Type().Elem().Kind() != reflect.String && v.Field(i).Type().Elem().Kind() != reflect.Int { - continue - } - } - - // create prompts - prompt := promptui.Prompt{ - Label: fmt.Sprintf("Enter %s %s", namePrefix, strings.ToLower(client.CamelCaseToString(v.Type().Field(i).Name))), - Validate: client.ValidatePromptNotEmpty, - } - - fieldName := strings.ToLower(v.Type().Field(i).Name) - - if strings.EqualFold(fieldName, "authority") { - // pre-fill with gov address - defaultAddr, err := addressCodec.BytesToString(authtypes.NewModuleAddress(types.ModuleName)) - if err != nil { - return data, err - } - prompt.Default = defaultAddr - prompt.Validate = client.ValidatePromptAddress - } - - // TODO(@julienrbrt) use scalar annotation instead of dumb string name matching - if strings.Contains(fieldName, "addr") || - strings.Contains(fieldName, "sender") || - strings.Contains(fieldName, "voter") || - strings.Contains(fieldName, "depositor") || - strings.Contains(fieldName, "granter") || - strings.Contains(fieldName, "grantee") || - strings.Contains(fieldName, "recipient") { - prompt.Validate = client.ValidatePromptAddress - } - - result, err := prompt.Run() - if err != nil { - return data, fmt.Errorf("failed to prompt for %s: %w", fieldName, err) - } - - switch v.Field(i).Kind() { - case reflect.String: - v.Field(i).SetString(result) - case reflect.Int: - resultInt, err := strconv.ParseInt(result, 10, 0) - if err != nil { - return data, fmt.Errorf("invalid value for int: %w", err) - } - // If a value was successfully parsed the ranges of: - // [minInt, maxInt] - // are within the ranges of: - // [minInt64, maxInt64] - // of which on 64-bit machines, which are most common, - // int==int64 - v.Field(i).SetInt(resultInt) - case reflect.Slice: - switch v.Field(i).Type().Elem().Kind() { - case reflect.String: - v.Field(i).Set(reflect.ValueOf([]string{result})) - case reflect.Int: - resultInt, err := strconv.ParseInt(result, 10, 0) - if err != nil { - return data, fmt.Errorf("invalid value for int: %w", err) - } - - v.Field(i).Set(reflect.ValueOf([]int{int(resultInt)})) - } - default: - // skip any other types - continue - } - } - - return data, nil -} - type proposalType struct { Name string MsgType string @@ -163,8 +68,8 @@ type proposalType struct { } // Prompt the proposal type values and return the proposal and its metadata -func (p *proposalType) Prompt(cdc codec.Codec, skipMetadata bool, addressCodec address.Codec) (*proposal, types.ProposalMetadata, error) { - metadata, err := PromptMetadata(skipMetadata, addressCodec) +func (p *proposalType) Prompt(cdc codec.Codec, skipMetadata bool, addressCodec, validatorAddressCodec, consensusAddressCodec address.Codec) (*proposal, types.ProposalMetadata, error) { + metadata, err := PromptMetadata(skipMetadata) if err != nil { return nil, metadata, fmt.Errorf("failed to set proposal metadata: %w", err) } @@ -176,11 +81,7 @@ func (p *proposalType) Prompt(cdc codec.Codec, skipMetadata bool, addressCodec a } // set deposit - depositPrompt := promptui.Prompt{ - Label: "Enter proposal deposit", - Validate: client.ValidatePromptCoins, - } - proposal.Deposit, err = depositPrompt.Run() + proposal.Deposit, err = prompt.PromptString("Enter proposal deposit", ValidatePromptCoins) if err != nil { return nil, metadata, fmt.Errorf("failed to set proposal deposit: %w", err) } @@ -190,12 +91,35 @@ func (p *proposalType) Prompt(cdc codec.Codec, skipMetadata bool, addressCodec a } // set messages field - result, err := Prompt(p.Msg, "msg", addressCodec) + msg, err := protoregistry.GlobalTypes.FindMessageByURL(p.MsgType) + if err != nil { + return nil, metadata, fmt.Errorf("failed to find proposal msg: %w", err) + } + newMsg := msg.New() + govAddr := sdkaddress.Module(types.ModuleName) + govAddrStr, err := addressCodec.BytesToString(govAddr) + if err != nil { + return nil, metadata, fmt.Errorf("failed to convert gov address to string: %w", err) + } + + prompt.SetDefaults(newMsg, map[string]interface{}{"authority": govAddrStr}) + result, err := prompt.PromptMessage(addressCodec, validatorAddressCodec, consensusAddressCodec, "msg", newMsg) if err != nil { return nil, metadata, fmt.Errorf("failed to set proposal message: %w", err) } - message, err := cdc.MarshalInterfaceJSON(result) + // message must be converted to gogoproto so @type is not lost + resultBytes, err := proto.Marshal(result.Interface()) + if err != nil { + return nil, metadata, fmt.Errorf("failed to marshal proposal message: %w", err) + } + + err = gogoproto.Unmarshal(resultBytes, p.Msg) + if err != nil { + return nil, metadata, fmt.Errorf("failed to unmarshal proposal message: %w", err) + } + + message, err := cdc.MarshalInterfaceJSON(p.Msg) if err != nil { return nil, metadata, fmt.Errorf("failed to marshal proposal message: %w", err) } @@ -214,33 +138,22 @@ func getProposalSuggestions() []string { } // PromptMetadata prompts for proposal metadata or only title and summary if skip is true -func PromptMetadata(skip bool, addressCodec address.Codec) (types.ProposalMetadata, error) { +func PromptMetadata(skip bool) (types.ProposalMetadata, error) { if !skip { - metadata, err := Prompt(types.ProposalMetadata{}, "proposal", addressCodec) + metadata, err := prompt.PromptStruct("proposal", types.ProposalMetadata{}) if err != nil { - return metadata, fmt.Errorf("failed to set proposal metadata: %w", err) + return types.ProposalMetadata{}, err } return metadata, nil } - // prompt for title and summary - titlePrompt := promptui.Prompt{ - Label: "Enter proposal title", - Validate: client.ValidatePromptNotEmpty, - } - - title, err := titlePrompt.Run() + title, err := prompt.PromptString("Enter proposal title", ValidatePromptNotEmpty) if err != nil { return types.ProposalMetadata{}, fmt.Errorf("failed to set proposal title: %w", err) } - summaryPrompt := promptui.Prompt{ - Label: "Enter proposal summary", - Validate: client.ValidatePromptNotEmpty, - } - - summary, err := summaryPrompt.Run() + summary, err := prompt.PromptString("Enter proposal summary", ValidatePromptNotEmpty) if err != nil { return types.ProposalMetadata{}, fmt.Errorf("failed to set proposal summary: %w", err) } @@ -262,17 +175,10 @@ func NewCmdDraftProposal() *cobra.Command { return err } - // prompt proposal type - proposalTypesPrompt := promptui.Select{ - Label: "Select proposal type", - Items: getProposalSuggestions(), - } - - _, selectedProposalType, err := proposalTypesPrompt.Run() + selectedProposalType, err := prompt.Select("Select proposal type", getProposalSuggestions()) if err != nil { return fmt.Errorf("failed to prompt proposal types: %w", err) } - var proposal proposalType for _, p := range suggestedProposalTypes { if strings.EqualFold(p.Name, selectedProposalType) { @@ -283,17 +189,10 @@ func NewCmdDraftProposal() *cobra.Command { // create any proposal type if proposal.Name == proposalOther { - // prompt proposal type - msgPrompt := promptui.Select{ - Label: "Select proposal message type:", - Items: func() []string { - msgs := clientCtx.InterfaceRegistry.ListImplementations(sdk.MsgInterfaceProtoName) - sort.Strings(msgs) - return msgs - }(), - } + msgs := clientCtx.InterfaceRegistry.ListImplementations(sdk.MsgInterfaceProtoName) + sort.Strings(msgs) - _, result, err := msgPrompt.Run() + result, err := prompt.Select("Select proposal message type:", msgs) if err != nil { return fmt.Errorf("failed to prompt proposal types: %w", err) } @@ -311,7 +210,7 @@ func NewCmdDraftProposal() *cobra.Command { skipMetadataPrompt, _ := cmd.Flags().GetBool(flagSkipMetadata) - result, metadata, err := proposal.Prompt(clientCtx.Codec, skipMetadataPrompt, clientCtx.AddressCodec) + result, metadata, err := proposal.Prompt(clientCtx.Codec, skipMetadataPrompt, clientCtx.AddressCodec, clientCtx.ValidatorAddressCodec, clientCtx.ConsensusAddressCodec) if err != nil { return err } diff --git a/x/gov/client/cli/prompt_test.go b/x/gov/client/cli/prompt_test.go deleted file mode 100644 index 359c9dea5b53..000000000000 --- a/x/gov/client/cli/prompt_test.go +++ /dev/null @@ -1,90 +0,0 @@ -//go:build !race -// +build !race - -// Disabled -race because the package github.com/manifoldco/promptui@v0.9.0 -// has a data race and this code exposes it, but fixing it would require -// holding up the associated change to this. - -package cli_test - -import ( - "fmt" - "math" - "os" - "testing" - - "github.com/chzyer/readline" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "cosmossdk.io/x/gov/client/cli" - - codectestutil "github.com/cosmos/cosmos-sdk/codec/testutil" -) - -type st struct { - I int -} - -// Tests that we successfully report overflows in parsing ints -// See https://github.com/cosmos/cosmos-sdk/issues/13346 -func TestPromptIntegerOverflow(t *testing.T) { - // Intentionally sending values out of the range of int. - intOverflowers := []string{ - "-9223372036854775809", - "9223372036854775808", - "9923372036854775809", - "-9923372036854775809", - "18446744073709551616", - "-18446744073709551616", - } - - for _, intOverflower := range intOverflowers { - overflowStr := intOverflower - t.Run(overflowStr, func(t *testing.T) { - origStdin := readline.Stdin - defer func() { - readline.Stdin = origStdin - }() - - fin, fw := readline.NewFillableStdin(os.Stdin) - readline.Stdin = fin - _, err := fw.Write([]byte(overflowStr + "\n")) - assert.NoError(t, err) - - v, err := cli.Prompt(st{}, "", codectestutil.CodecOptions{}.GetAddressCodec()) - assert.Equal(t, st{}, v, "expected a value of zero") - require.NotNil(t, err, "expected a report of an overflow") - require.Contains(t, err.Error(), "range") - }) - } -} - -func TestPromptParseInteger(t *testing.T) { - // Intentionally sending a value out of the range of - values := []struct { - in string - want int - }{ - {fmt.Sprintf("%d", math.MinInt), math.MinInt}, - {"19991", 19991}, - {"991000000199", 991000000199}, - } - - for _, tc := range values { - t.Run(tc.in, func(t *testing.T) { - origStdin := readline.Stdin - defer func() { - readline.Stdin = origStdin - }() - - fin, fw := readline.NewFillableStdin(os.Stdin) - readline.Stdin = fin - _, err := fw.Write([]byte(tc.in + "\n")) - assert.NoError(t, err) - v, err := cli.Prompt(st{}, "", codectestutil.CodecOptions{}.GetAddressCodec()) - assert.Nil(t, err, "expected a nil error") - assert.Equal(t, tc.want, v.I, "expected %d = %d", tc.want, v.I) - }) - } -} diff --git a/x/gov/client/cli/util.go b/x/gov/client/cli/util.go index f8628f9cffed..cdf76268b1da 100644 --- a/x/gov/client/cli/util.go +++ b/x/gov/client/cli/util.go @@ -209,3 +209,21 @@ func ReadGovPropFlags(clientCtx client.Context, flagSet *pflag.FlagSet) (*govv1. return ReadGovPropCmdFlags(addr, flagSet) } + +// ValidatePromptCoins validates that the input contains valid sdk.Coins +func ValidatePromptCoins(input string) error { + if _, err := sdk.ParseCoinsNormalized(input); err != nil { + return fmt.Errorf("invalid coins: %w", err) + } + + return nil +} + +// ValidatePromptNotEmpty validates that the input is not empty. +func ValidatePromptNotEmpty(input string) error { + if input == "" { + return errors.New("input cannot be empty") + } + + return nil +} diff --git a/x/gov/client/cli/util_test.go b/x/gov/client/cli/util_test.go index 2601a526fe1b..e5ad5efcc29b 100644 --- a/x/gov/client/cli/util_test.go +++ b/x/gov/client/cli/util_test.go @@ -714,3 +714,17 @@ func TestReadGovPropFlags(t *testing.T) { }) } } + +func TestValidatePromptNotEmpty(t *testing.T) { + require := require.New(t) + + require.NoError(ValidatePromptNotEmpty("foo")) + require.ErrorContains(ValidatePromptNotEmpty(""), "input cannot be empty") +} + +func TestValidatePromptCoins(t *testing.T) { + require := require.New(t) + + require.NoError(ValidatePromptCoins("100stake")) + require.ErrorContains(ValidatePromptCoins("foo"), "invalid coins") +} diff --git a/x/gov/go.mod b/x/gov/go.mod index 208c7fda324c..f35867bb0486 100644 --- a/x/gov/go.mod +++ b/x/gov/go.mod @@ -4,6 +4,7 @@ go 1.23.3 require ( cosmossdk.io/api v0.7.6 + cosmossdk.io/client/v2 v2.0.0-beta.6 cosmossdk.io/collections v0.4.1-0.20241128094659-bd76b47e1d8b cosmossdk.io/core v1.0.0-alpha.6 cosmossdk.io/core/testing v0.0.0-20241108153815-606544c7be7e @@ -15,14 +16,14 @@ require ( cosmossdk.io/x/bank v0.0.0-20240226161501-23359a0b6d91 cosmossdk.io/x/protocolpool v0.0.0-20230925135524-a1bc045b3190 cosmossdk.io/x/staking v0.0.0-00010101000000-000000000000 - github.com/chzyer/readline v1.5.1 + github.com/chzyer/readline v1.5.1 // indirect github.com/cometbft/cometbft v1.0.0-rc2.0.20241127125717-4ce33b646ac9 // indirect github.com/cosmos/cosmos-proto v1.0.0-beta.5 github.com/cosmos/cosmos-sdk v0.53.0 github.com/cosmos/gogoproto v1.7.0 github.com/golang/protobuf v1.5.4 github.com/grpc-ecosystem/grpc-gateway v1.16.0 - github.com/manifoldco/promptui v0.9.0 + github.com/manifoldco/promptui v0.9.0 // indirect github.com/spf13/cobra v1.8.1 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.10.0 @@ -181,6 +182,7 @@ replace github.com/cosmos/cosmos-sdk => ../../. // TODO remove post spinning out all modules replace ( cosmossdk.io/api => ../../api + cosmossdk.io/client/v2 => ../../client/v2 cosmossdk.io/store => ../../store cosmossdk.io/x/bank => ../bank cosmossdk.io/x/protocolpool => ../protocolpool diff --git a/x/group/client/cli/prompt.go b/x/group/client/cli/prompt.go index 3cb018e02548..f3677c485059 100644 --- a/x/group/client/cli/prompt.go +++ b/x/group/client/cli/prompt.go @@ -6,9 +6,12 @@ import ( "os" "sort" - "github.com/manifoldco/promptui" + gogoproto "github.com/cosmos/gogoproto/proto" "github.com/spf13/cobra" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoregistry" + "cosmossdk.io/client/v2/autocli/prompt" "cosmossdk.io/core/address" govcli "cosmossdk.io/x/gov/client/cli" govtypes "cosmossdk.io/x/gov/types" @@ -27,14 +30,15 @@ const ( ) type proposalType struct { - Name string - Msg sdk.Msg + Name string + MsgType string + Msg sdk.Msg } // Prompt the proposal type values and return the proposal and its metadata. -func (p *proposalType) Prompt(cdc codec.Codec, skipMetadata bool, addressCodec address.Codec) (*Proposal, govtypes.ProposalMetadata, error) { +func (p *proposalType) Prompt(cdc codec.Codec, skipMetadata bool, addressCodec, validatorAddressCodec, consensusAddressCodec address.Codec) (*Proposal, govtypes.ProposalMetadata, error) { // set metadata - metadata, err := govcli.PromptMetadata(skipMetadata, addressCodec) + metadata, err := govcli.PromptMetadata(skipMetadata) if err != nil { return nil, metadata, fmt.Errorf("failed to set proposal metadata: %w", err) } @@ -46,22 +50,14 @@ func (p *proposalType) Prompt(cdc codec.Codec, skipMetadata bool, addressCodec a } // set group policy address - policyAddressPrompt := promptui.Prompt{ - Label: "Enter group policy address", - Validate: client.ValidatePromptAddress, - } - groupPolicyAddress, err := policyAddressPrompt.Run() + groupPolicyAddress, err := prompt.PromptString("Enter group policy address", prompt.ValidateAddress(addressCodec)) if err != nil { return nil, metadata, fmt.Errorf("failed to set group policy address: %w", err) } proposal.GroupPolicyAddress = groupPolicyAddress // set proposer address - proposerPrompt := promptui.Prompt{ - Label: "Enter proposer address", - Validate: client.ValidatePromptAddress, - } - proposerAddress, err := proposerPrompt.Run() + proposerAddress, err := prompt.PromptString("Enter proposer address", prompt.ValidateAddress(addressCodec)) if err != nil { return nil, metadata, fmt.Errorf("failed to set proposer address: %w", err) } @@ -72,12 +68,29 @@ func (p *proposalType) Prompt(cdc codec.Codec, skipMetadata bool, addressCodec a } // set messages field - result, err := govcli.Prompt(p.Msg, "msg", addressCodec) + msg, err := protoregistry.GlobalTypes.FindMessageByURL(p.MsgType) + if err != nil { + return nil, metadata, fmt.Errorf("failed to find proposal msg: %w", err) + } + newMsg := msg.New() + + result, err := prompt.PromptMessage(addressCodec, validatorAddressCodec, consensusAddressCodec, "msg", newMsg) if err != nil { return nil, metadata, fmt.Errorf("failed to set proposal message: %w", err) } - message, err := cdc.MarshalInterfaceJSON(result) + // message must be converted to gogoproto so @type is not lost + resultBytes, err := proto.Marshal(result.Interface()) + if err != nil { + return nil, metadata, fmt.Errorf("failed to marshal proposal message: %w", err) + } + + err = gogoproto.Unmarshal(resultBytes, p.Msg) + if err != nil { + return nil, metadata, fmt.Errorf("failed to unmarshal proposal message: %w", err) + } + + message, err := cdc.MarshalInterfaceJSON(p.Msg) if err != nil { return nil, metadata, fmt.Errorf("failed to marshal proposal message: %w", err) } @@ -101,12 +114,7 @@ func NewCmdDraftProposal() *cobra.Command { } // prompt proposal type - proposalTypesPrompt := promptui.Select{ - Label: "Select proposal type", - Items: []string{proposalText, proposalOther}, - } - - _, selectedProposalType, err := proposalTypesPrompt.Run() + selectedProposalType, err := prompt.Select("Select proposal type", []string{proposalText, proposalOther}) if err != nil { return fmt.Errorf("failed to prompt proposal types: %w", err) } @@ -118,20 +126,15 @@ func NewCmdDraftProposal() *cobra.Command { case proposalOther: // prompt proposal type proposal = &proposalType{Name: proposalOther} - msgPrompt := promptui.Select{ - Label: "Select proposal message type:", - Items: func() []string { - msgs := clientCtx.InterfaceRegistry.ListImplementations(sdk.MsgInterfaceProtoName) - sort.Strings(msgs) - return msgs - }(), - } - _, result, err := msgPrompt.Run() + msgs := clientCtx.InterfaceRegistry.ListImplementations(sdk.MsgInterfaceProtoName) + sort.Strings(msgs) + + result, err := prompt.Select("Select proposal message type:", msgs) if err != nil { return fmt.Errorf("failed to prompt proposal types: %w", err) } - + proposal.MsgType = result proposal.Msg, err = sdk.GetMsgFromTypeURL(clientCtx.Codec, result) if err != nil { // should never happen @@ -143,7 +146,7 @@ func NewCmdDraftProposal() *cobra.Command { skipMetadataPrompt, _ := cmd.Flags().GetBool(flagSkipMetadata) - result, metadata, err := proposal.Prompt(clientCtx.Codec, skipMetadataPrompt, clientCtx.AddressCodec) + result, metadata, err := proposal.Prompt(clientCtx.Codec, skipMetadataPrompt, clientCtx.AddressCodec, clientCtx.ValidatorAddressCodec, clientCtx.ConsensusAddressCodec) if err != nil { return err } diff --git a/x/group/go.mod b/x/group/go.mod index c23406794e66..059b237bc831 100644 --- a/x/group/go.mod +++ b/x/group/go.mod @@ -4,6 +4,7 @@ go 1.23.3 require ( cosmossdk.io/api v0.7.6 + cosmossdk.io/client/v2 v2.0.0-beta.6 cosmossdk.io/core v1.0.0-alpha.6 cosmossdk.io/core/testing v0.0.0-20241108153815-606544c7be7e cosmossdk.io/depinject v1.1.0 @@ -15,7 +16,7 @@ require ( cosmossdk.io/x/authz v0.0.0-00010101000000-000000000000 cosmossdk.io/x/bank v0.0.0-20240226161501-23359a0b6d91 cosmossdk.io/x/consensus v0.0.0-00010101000000-000000000000 - cosmossdk.io/x/gov v0.0.0-20230925135524-a1bc045b3190 + cosmossdk.io/x/gov v0.0.0-20231113122742-912390d5fc4a cosmossdk.io/x/mint v0.0.0-00010101000000-000000000000 cosmossdk.io/x/staking v0.0.0-00010101000000-000000000000 github.com/cockroachdb/apd/v3 v3.2.1 @@ -24,7 +25,6 @@ require ( github.com/cosmos/gogoproto v1.7.0 github.com/golang/protobuf v1.5.4 github.com/grpc-ecosystem/grpc-gateway v1.16.0 - github.com/manifoldco/promptui v0.9.0 github.com/spf13/cobra v1.8.1 github.com/stretchr/testify v1.10.0 go.uber.org/mock v0.5.0 @@ -120,6 +120,7 @@ require ( github.com/lib/pq v1.10.9 // indirect github.com/linxGnu/grocksdb v1.9.3 // indirect github.com/magiconair/properties v1.8.9 // indirect + github.com/manifoldco/promptui v0.9.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/minio/highwayhash v1.0.3 // indirect @@ -184,6 +185,7 @@ replace github.com/cosmos/cosmos-sdk => ../../ // TODO remove post spinning out all modules replace ( cosmossdk.io/api => ../../api + cosmossdk.io/client/v2 => ../../client/v2 cosmossdk.io/store => ../../store cosmossdk.io/x/accounts => ../accounts cosmossdk.io/x/accounts/defaults/base => ../accounts/defaults/base diff --git a/x/upgrade/go.mod b/x/upgrade/go.mod index 5d71a9feff83..fa3d43e9a917 100644 --- a/x/upgrade/go.mod +++ b/x/upgrade/go.mod @@ -10,7 +10,7 @@ require ( cosmossdk.io/errors v1.0.1 cosmossdk.io/log v1.5.0 cosmossdk.io/store v1.1.1-0.20240418092142-896cdf1971bc - cosmossdk.io/x/gov v0.0.0-20230925135524-a1bc045b3190 + cosmossdk.io/x/gov v0.0.0-20231113122742-912390d5fc4a github.com/cometbft/cometbft v1.0.0-rc2.0.20241127125717-4ce33b646ac9 github.com/cometbft/cometbft/api v1.0.0-rc2 github.com/cosmos/cosmos-proto v1.0.0-beta.5 @@ -40,6 +40,7 @@ require ( cloud.google.com/go/compute/metadata v0.5.0 // indirect cloud.google.com/go/iam v1.1.13 // indirect cloud.google.com/go/storage v1.43.0 // indirect + cosmossdk.io/client/v2 v2.0.0-beta.6 // indirect cosmossdk.io/collections v0.4.1-0.20241128094659-bd76b47e1d8b // indirect cosmossdk.io/math v1.4.0 // indirect cosmossdk.io/schema v0.4.0 // indirect @@ -206,6 +207,7 @@ replace github.com/cosmos/cosmos-sdk => ../../. replace ( cosmossdk.io/api => ../../api + cosmossdk.io/client/v2 => ../../client/v2 cosmossdk.io/store => ../../store cosmossdk.io/x/bank => ../bank cosmossdk.io/x/gov => ../gov