From ef0f19cf282e24c90b8713d66dc015fbf2bc53a6 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 19 Sep 2022 13:35:08 +0000 Subject: [PATCH] feat: CLI tooling to generate proposal JSONs (backport #13304) (#13328) * feat: CLI tooling to generate proposal JSONs (#13304) (cherry picked from commit 7252f4a75893868a22e43905170b6258c2e97767) # Conflicts: # CHANGELOG.md # api/cosmos/nft/v1beta1/tx.pulsar.go # go.mod # go.sum # simapp/go.mod # simapp/go.sum # tests/go.mod # tests/go.sum # x/gov/README.md # x/group/spec/05_client.md * fix changelog * remove unnecessary addition * updates * fix docs * updates Co-authored-by: Julien Robert --- CHANGELOG.md | 1 + client/prompts.go | 57 ++++ go.mod | 10 +- go.sum | 19 +- x/gov/client/cli/prompt.go | 297 ++++++++++++++++++ x/gov/client/cli/tx.go | 1 + x/gov/client/cli/{parse.go => util.go} | 6 +- .../cli/{parse_test.go => util_test.go} | 0 x/gov/spec/07_client.md | 10 + x/gov/spec/08_metadata.md | 8 +- x/group/spec/06_metadata.md | 14 +- 11 files changed, 402 insertions(+), 21 deletions(-) create mode 100644 client/prompts.go create mode 100644 x/gov/client/cli/prompt.go rename x/gov/client/cli/{parse.go => util.go} (94%) rename x/gov/client/cli/{parse_test.go => util_test.go} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1032f3b13184..8ee98b7f3d91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ Ref: https://keepachangelog.com/en/1.0.0/ ### Features +* (cli) [#13304](https://github.com/cosmos/cosmos-sdk/pull/13304) Add `tx gov draft-proposal` command for generating proposal JSONs. * (x/authz) [#13047](https://github.com/cosmos/cosmos-sdk/pull/13047) Add a GetAuthorization function to the keeper. * (cli) [#12742](https://github.com/cosmos/cosmos-sdk/pull/12742) Add the `prune` CLI cmd to manually prune app store history versions based on the pruning options. diff --git a/client/prompts.go b/client/prompts.go new file mode 100644 index 000000000000..050d806c49a8 --- /dev/null +++ b/client/prompts.go @@ -0,0 +1,57 @@ +package client + +import ( + "fmt" + "net/url" + "unicode" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// ValidatePromptNotEmpty validates that the input is not empty. +func ValidatePromptNotEmpty(input string) error { + if input == "" { + return fmt.Errorf("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 +} + +// ValidatePromptAddress validates that the input is a valid Bech32 address. +func ValidatePromptAddress(input string) error { + if _, err := sdk.AccAddressFromBech32(input); err != nil { + return fmt.Errorf("invalid address: %w", err) + } + + return nil +} + +// ValidatePromptYesNo validates that the input is valid sdk.COins +func ValidatePromptCoins(input string) error { + if _, err := sdk.ParseCoinsNormalized(input); err != nil { + return fmt.Errorf("invalid coins: %w", err) + } + + return nil +} + +// CamelCaseToString converts a camel case string to a string with spaces. +func CamelCaseToString(str string) string { + w := []rune(str) + for i := len(w) - 1; i > 1; i-- { + if unicode.IsUpper(w[i]) { + w = append(w[:i], append([]rune{' '}, w[i:]...)...) + } + } + return string(w) +} diff --git a/go.mod b/go.mod index d16833ef31d0..69d3f5f6f10d 100644 --- a/go.mod +++ b/go.mod @@ -34,7 +34,8 @@ require ( github.com/jhump/protoreflect v1.12.1-0.20220721211354-060cc04fc18b github.com/lazyledger/smt v0.2.1-0.20210709230900-03ea40719554 github.com/magiconair/properties v1.8.6 - github.com/mattn/go-isatty v0.0.14 + github.com/manifoldco/promptui v0.9.0 + github.com/mattn/go-isatty v0.0.16 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.12.2 github.com/prometheus/common v0.34.0 @@ -54,8 +55,8 @@ require ( golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e google.golang.org/genproto v0.0.0-20220725144611-272f38e5d71b - google.golang.org/grpc v1.48.0 - google.golang.org/protobuf v1.28.0 + google.golang.org/grpc v1.49.0 + google.golang.org/protobuf v1.28.1 pgregory.net/rapid v0.4.7 sigs.k8s.io/yaml v1.3.0 ) @@ -75,6 +76,7 @@ require ( github.com/cenkalti/backoff/v4 v4.1.3 // indirect github.com/cespare/xxhash v1.1.0 // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect + github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect github.com/cosmos/gorocksdb v1.2.0 // indirect github.com/cosmos/ledger-go v0.9.2 // indirect github.com/creachadair/taskgroup v0.3.2 // indirect @@ -140,7 +142,7 @@ require ( go.opencensus.io v0.23.0 // indirect golang.org/x/net v0.0.0-20220726230323-06994584191e // indirect golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5 // indirect - golang.org/x/sys v0.0.0-20220727055044-e65921a090b8 // indirect + golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect golang.org/x/term v0.0.0-20220722155259-a9ba230a4035 // indirect golang.org/x/text v0.3.7 // indirect golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df // indirect diff --git a/go.sum b/go.sum index 9d676c4acb50..7e0a783a9835 100644 --- a/go.sum +++ b/go.sum @@ -186,8 +186,11 @@ github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cheggaaa/pb v1.0.27/go.mod h1:pQciLPpbU0oxA0h+VJYYLxO+XeDQb5pZijXscXHm81s= +github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= @@ -666,6 +669,8 @@ github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamh github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= +github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/matryer/moq v0.0.0-20190312154309-6cfb0558e1bd/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= @@ -679,8 +684,9 @@ github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNx github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= @@ -1265,8 +1271,8 @@ golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220517195934-5e4e11fc645e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220727055044-e65921a090b8 h1:dyU22nBWzrmTQxtNrr4dzVOvaw35nUYE279vF9UmsI8= -golang.org/x/sys v0.0.0-20220727055044-e65921a090b8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -1535,8 +1541,8 @@ google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ5 google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= -google.golang.org/grpc v1.48.0 h1:rQOsyJ/8+ufEDJd/Gdsz7HG220Mh9HAhFHRGnIjda0w= -google.golang.org/grpc v1.48.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= +google.golang.org/grpc v1.49.0 h1:WTLtQzmQori5FUH25Pq4WT22oCsv8USpQ+F6rqtsmxw= +google.golang.org/grpc v1.49.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= @@ -1551,8 +1557,9 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= +google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/x/gov/client/cli/prompt.go b/x/gov/client/cli/prompt.go new file mode 100644 index 000000000000..b997f5a4a8b9 --- /dev/null +++ b/x/gov/client/cli/prompt.go @@ -0,0 +1,297 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + "reflect" + "sort" + "strconv" + "strings" + + "github.com/manifoldco/promptui" + "github.com/spf13/cobra" + + "github.com/cosmos/cosmos-sdk/client" + "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" + "github.com/cosmos/cosmos-sdk/x/gov/types" +) + +const ( + proposalText = "text" + proposalOther = "other" + draftProposalFileName = "draft_proposal.json" + draftMetadataFileName = "draft_metadata.json" +) + +// ProposalMetadata is the metadata of a proposal +// This metadata is supposed to live off-chain when submitted in a proposal +type ProposalMetadata struct { + Title string `json:"title"` + Authors string `json:"authors"` + Summary string `json:"summary"` + Details string `json:"details"` + ProposalForumUrl string `json:"proposal_forum_url"` // named 'Url' instead of 'URL' for avoiding the camel case split + VoteOptionContext string `json:"vote_option_context"` +} + +// Prompt prompts the user for all values of the given type. +// data is the struct to be filled +// namePrefix is the name to be display as "Enter " +func Prompt[T any](data T, namePrefix string) (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 v.Field(i).Kind() == reflect.Struct || v.Field(i).Kind() == reflect.Slice { + // if the field is a struct skip + // in a future we can add a recursive call to Prompt + 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) + // validation per field name + if strings.Contains(fieldName, "url") { + prompt.Validate = client.ValidatePromptURL + } + + if strings.EqualFold(fieldName, "authority") { + // pre-fill with gov address + prompt.Default = authtypes.NewModuleAddress(types.ModuleName).String() + prompt.Validate = client.ValidatePromptAddress + } + + 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, _ := strconv.Atoi(result) + v.Field(i).SetInt(int64(resultInt)) + default: + // skip other types + // possibly in the future we can add more types (like slices) + continue + } + } + + return data, nil +} + +type proposalTypes struct { + Type string + MsgType string + Msg sdk.Msg +} + +// Prompt the proposal type values and return the proposal and its metadata +func (p *proposalTypes) Prompt(cdc codec.Codec) (*proposal, ProposalMetadata, error) { + proposal := &proposal{} + + // set metadata + metadata, err := Prompt(ProposalMetadata{}, "proposal") + if err != nil { + return nil, metadata, fmt.Errorf("failed to set proposal metadata: %w", err) + } + proposal.Metadata = "ipfs://CID" + + // set deposit + depositPrompt := promptui.Prompt{ + Label: "Enter proposal deposit", + Validate: client.ValidatePromptCoins, + } + proposal.Deposit, err = depositPrompt.Run() + if err != nil { + return nil, metadata, fmt.Errorf("failed to set proposal deposit: %w", err) + } + + if p.Msg == nil { + return proposal, metadata, nil + } + + // set messages field + result, err := Prompt(p.Msg, "msg") + if err != nil { + return nil, metadata, fmt.Errorf("failed to set proposal message: %w", err) + } + + message, err := cdc.MarshalInterfaceJSON(result) + if err != nil { + return nil, metadata, fmt.Errorf("failed to marshal proposal message: %w", err) + } + proposal.Messages = append(proposal.Messages, message) + return proposal, metadata, nil +} + +var supportedProposalTypes = []proposalTypes{ + { + Type: proposalText, + MsgType: "", // no message for text proposal + }, + { + Type: "community-pool-spend", + MsgType: "/cosmos.distribution.v1beta1.MsgCommunityPoolSpend", + }, + { + Type: "software-upgrade", + MsgType: "/cosmos.upgrade.v1beta1.MsgSoftwareUpgrade", + }, + { + Type: "cancel-software-upgrade", + MsgType: "/cosmos.upgrade.v1beta1.MsgCancelUpgrade", + }, + { + Type: proposalOther, + MsgType: "", // user will input the message type + }, +} + +func getProposalTypes() []string { + types := make([]string, len(supportedProposalTypes)) + for i, p := range supportedProposalTypes { + types[i] = p.Type + } + return types +} + +func getProposalMsg(cdc codec.Codec, input string) (sdk.Msg, error) { + var msg sdk.Msg + bz, err := json.Marshal(struct { + Type string `json:"@type"` + }{ + Type: input, + }) + if err != nil { + return nil, err + } + + if err := cdc.UnmarshalInterfaceJSON(bz, &msg); err != nil { + return nil, fmt.Errorf("failed to determined sdk.Msg from %s proposal type : %w", input, err) + } + + return msg, nil +} + +// NewCmdDraftProposal let a user generate a draft proposal. +func NewCmdDraftProposal() *cobra.Command { + cmd := &cobra.Command{ + Use: "draft-proposal", + Short: "Generate a draft proposal json file. The generated proposal json contains only one message.", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + clientCtx, err := client.GetClientTxContext(cmd) + if err != nil { + return err + } + + // prompt proposal type + proposalTypesPrompt := promptui.Select{ + Label: "Select proposal type", + Items: getProposalTypes(), + } + + _, proposalType, err := proposalTypesPrompt.Run() + if err != nil { + return fmt.Errorf("failed to prompt proposal types: %w", err) + } + + var proposal proposalTypes + for _, p := range supportedProposalTypes { + if strings.EqualFold(p.Type, proposalType) { + proposal = p + break + } + } + + // create any proposal type + if proposal.Type == 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 + }(), + } + + _, result, err := msgPrompt.Run() + if err != nil { + return fmt.Errorf("failed to prompt proposal types: %w", err) + } + + proposal.MsgType = result + } + + if proposal.MsgType != "" { + proposal.Msg, err = getProposalMsg(clientCtx.Codec, proposal.MsgType) + if err != nil { + // should never happen + panic(err) + } + } + + prop, metadata, err := proposal.Prompt(clientCtx.Codec) + if err != nil { + return err + } + + if err := writeFile(draftMetadataFileName, metadata); err != nil { + return err + } + + if err := writeFile(draftProposalFileName, prop); err != nil { + return err + } + + fmt.Printf("Your draft proposal has successfully been generated.\nProposals should contain off-chain metadata, please upload the metadata JSON to IPFS.\nThen, replace the generated metadata field with the IPFS CID.\n") + + return nil + }, + } + + flags.AddTxFlagsToCmd(cmd) + + return cmd +} + +func writeFile(fileName string, input any) error { + raw, err := json.MarshalIndent(input, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal proposal: %w", err) + } + + if err := os.WriteFile(fileName, raw, 0o600); err != nil { + return err + } + + return nil +} diff --git a/x/gov/client/cli/tx.go b/x/gov/client/cli/tx.go index 85e821ddebee..f18c14874984 100644 --- a/x/gov/client/cli/tx.go +++ b/x/gov/client/cli/tx.go @@ -70,6 +70,7 @@ func NewTxCmd(legacyPropCmds []*cobra.Command) *cobra.Command { NewCmdVote(), NewCmdWeightedVote(), NewCmdSubmitProposal(), + NewCmdDraftProposal(), // Deprecated cmdSubmitLegacyProp, diff --git a/x/gov/client/cli/parse.go b/x/gov/client/cli/util.go similarity index 94% rename from x/gov/client/cli/parse.go rename to x/gov/client/cli/util.go index a43d7f868cea..64517fab22c6 100644 --- a/x/gov/client/cli/parse.go +++ b/x/gov/client/cli/util.go @@ -77,9 +77,9 @@ func parseSubmitLegacyProposalFlags(fs *pflag.FlagSet) (*legacyProposal, error) // proposal defines the new Msg-based proposal. type proposal struct { // Msgs defines an array of sdk.Msgs proto-JSON-encoded as Anys. - Messages []json.RawMessage - Metadata string - Deposit string + Messages []json.RawMessage `json:"messages,omitempty"` + Metadata string `json:"metadata"` + Deposit string `json:"deposit"` } func parseSubmitProposal(cdc codec.Codec, path string) ([]sdk.Msg, string, sdk.Coins, error) { diff --git a/x/gov/client/cli/parse_test.go b/x/gov/client/cli/util_test.go similarity index 100% rename from x/gov/client/cli/parse_test.go rename to x/gov/client/cli/util_test.go diff --git a/x/gov/spec/07_client.md b/x/gov/spec/07_client.md index 00aa57e57859..3450a052ef60 100644 --- a/x/gov/spec/07_client.md +++ b/x/gov/spec/07_client.md @@ -346,6 +346,16 @@ Example: simd tx gov deposit 1 10000000stake --from cosmos1.. ``` +#### draft-proposal + +The `draft-proposal` command allows users to draft any type of proposal. +The command returns a `draft_proposal.json`, to be used by `submit-proposal` after being completed. +The `draft_metadata.json` is meant to be uploaded to [IPFS](./08_metadata.md). + +```bash +simd tx gov draft-proposal +``` + #### submit-proposal The `submit-proposal` command allows users to submit a governance proposal along with some messages and metadata. diff --git a/x/gov/spec/08_metadata.md b/x/gov/spec/08_metadata.md index fcd161c76c6c..f5a0b3943fc4 100644 --- a/x/gov/spec/08_metadata.md +++ b/x/gov/spec/08_metadata.md @@ -7,6 +7,7 @@ order: 8 The gov module has two locations for metadata where users can provide further context about the on-chain actions they are taking. By default all metadata fields have a 255 character length field where metadata can be stored in json format, either on-chain or off-chain depending on the amount of data required. Here we provide a recommendation for the json structure and where the data should be stored. There are two important factors in making these recommendations. First, that the gov and group modules are consistent with one another, note the number of proposals made by all groups may be quite large. Second, that client applications such as block explorers and governance interfaces have confidence in the consistency of metadata structure accross chains. ## Proposal + Location: off-chain as json object stored on IPFS (mirrors [group proposal](../../group/spec/06_metadata.md#proposal)) ```json @@ -15,16 +16,17 @@ Location: off-chain as json object stored on IPFS (mirrors [group proposal](../. "authors": "", "summary": "", "details": "", - "proposalForumURL": "", - "voteOptionContext": "", + "proposal_forum_url": "", + "vote_option_context": "", } ``` ## Vote + Location: on-chain as json within 255 character limit (mirrors [group vote](../../group/spec/06_metadata.md#vote)) ```json { "justification": "", } -``` \ No newline at end of file +``` diff --git a/x/group/spec/06_metadata.md b/x/group/spec/06_metadata.md index 27108673557e..00853fd1c844 100644 --- a/x/group/spec/06_metadata.md +++ b/x/group/spec/06_metadata.md @@ -7,6 +7,7 @@ order: 6 The group module has four locations for metadata where users can provide further context about the on-chain actions they are taking. By default all metadata fields have a 255 character length field where metadata can be stored in json format, either on-chain or off-chain depending on the amount of data required. Here we provide a recommendation for the json structure and where the data should be stored. There are two important factors in making these recommendations. First, that the group and gov modules are consistent with one another, note the number of proposals made by all groups may be quite large. Second, that client applications such as block explorers and governance interfaces have confidence in the consistency of metadata structure accross chains. ## Proposal + Location: off-chain as json object stored on IPFS (mirrors [gov proposal](../../gov/spec/08_metadata.md#proposal)) ```json @@ -15,12 +16,13 @@ Location: off-chain as json object stored on IPFS (mirrors [gov proposal](../../ "authors": "", "summary": "", "details": "", - "proposalForumURL": "", - "voteOptionContext": "", + "proposal_forum_url": "", + "vote_option_context": "", } ``` ## Vote + Location: on-chain as json within 255 character limit (mirrors [gov vote](../../gov/spec/08_metadata.md#vote)) ```json @@ -30,18 +32,20 @@ Location: on-chain as json within 255 character limit (mirrors [gov vote](../../ ``` ## Group + Location: off-chain as json object stored on IPFS ```json { "name": "", "description": "", - "groupWebsiteURL": "", - "groupForumURL": "", + "group_website_url": "", + "group_forum_url": "", } ``` ## Decision policy + Location: on-chain as json within 255 character limit ```json @@ -49,4 +53,4 @@ Location: on-chain as json within 255 character limit "name": "", "description": "", } -``` \ No newline at end of file +```