github.com/cosmos/cosmos-sdk@v0.50.10/x/gov/client/cli/prompt.go (about) 1 package cli 2 3 import ( 4 "encoding/json" 5 "fmt" 6 "os" 7 "reflect" // #nosec 8 "sort" 9 "strconv" 10 "strings" 11 12 "github.com/manifoldco/promptui" 13 "github.com/spf13/cobra" 14 15 "github.com/cosmos/cosmos-sdk/client" 16 "github.com/cosmos/cosmos-sdk/client/flags" 17 "github.com/cosmos/cosmos-sdk/codec" 18 sdk "github.com/cosmos/cosmos-sdk/types" 19 authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" 20 "github.com/cosmos/cosmos-sdk/x/gov/types" 21 ) 22 23 const ( 24 proposalText = "text" 25 proposalOther = "other" 26 draftProposalFileName = "draft_proposal.json" 27 draftMetadataFileName = "draft_metadata.json" 28 ) 29 30 var suggestedProposalTypes = []proposalType{ 31 { 32 Name: proposalText, 33 MsgType: "", // no message for text proposal 34 }, 35 { 36 Name: "community-pool-spend", 37 MsgType: "/cosmos.distribution.v1beta1.MsgCommunityPoolSpend", 38 }, 39 { 40 Name: "software-upgrade", 41 MsgType: "/cosmos.upgrade.v1beta1.MsgSoftwareUpgrade", 42 }, 43 { 44 Name: "cancel-software-upgrade", 45 MsgType: "/cosmos.upgrade.v1beta1.MsgCancelUpgrade", 46 }, 47 { 48 Name: proposalOther, 49 MsgType: "", // user will input the message type 50 }, 51 } 52 53 // Prompt prompts the user for all values of the given type. 54 // data is the struct to be filled 55 // namePrefix is the name to be displayed as "Enter <namePrefix> <field>" 56 func Prompt[T any](data T, namePrefix string) (T, error) { 57 v := reflect.ValueOf(&data).Elem() 58 if v.Kind() == reflect.Interface { 59 v = reflect.ValueOf(data) 60 if v.Kind() == reflect.Ptr { 61 v = v.Elem() 62 } 63 } 64 65 for i := 0; i < v.NumField(); i++ { 66 // if the field is a struct skip or not slice of string or int then skip 67 switch v.Field(i).Kind() { 68 case reflect.Struct: 69 // TODO(@julienrbrt) in the future we can add a recursive call to Prompt 70 continue 71 case reflect.Slice: 72 if v.Field(i).Type().Elem().Kind() != reflect.String && v.Field(i).Type().Elem().Kind() != reflect.Int { 73 continue 74 } 75 } 76 77 // create prompts 78 prompt := promptui.Prompt{ 79 Label: fmt.Sprintf("Enter %s %s", namePrefix, strings.ToLower(client.CamelCaseToString(v.Type().Field(i).Name))), 80 Validate: client.ValidatePromptNotEmpty, 81 } 82 83 fieldName := strings.ToLower(v.Type().Field(i).Name) 84 85 if strings.EqualFold(fieldName, "authority") { 86 // pre-fill with gov address 87 prompt.Default = authtypes.NewModuleAddress(types.ModuleName).String() 88 prompt.Validate = client.ValidatePromptAddress 89 } 90 91 // TODO(@julienrbrt) use scalar annotation instead of dumb string name matching 92 if strings.Contains(fieldName, "addr") || 93 strings.Contains(fieldName, "sender") || 94 strings.Contains(fieldName, "voter") || 95 strings.Contains(fieldName, "depositor") || 96 strings.Contains(fieldName, "granter") || 97 strings.Contains(fieldName, "grantee") || 98 strings.Contains(fieldName, "recipient") { 99 prompt.Validate = client.ValidatePromptAddress 100 } 101 102 result, err := prompt.Run() 103 if err != nil { 104 return data, fmt.Errorf("failed to prompt for %s: %w", fieldName, err) 105 } 106 107 switch v.Field(i).Kind() { 108 case reflect.String: 109 v.Field(i).SetString(result) 110 case reflect.Int: 111 resultInt, err := strconv.ParseInt(result, 10, 0) 112 if err != nil { 113 return data, fmt.Errorf("invalid value for int: %w", err) 114 } 115 // If a value was successfully parsed the ranges of: 116 // [minInt, maxInt] 117 // are within the ranges of: 118 // [minInt64, maxInt64] 119 // of which on 64-bit machines, which are most common, 120 // int==int64 121 v.Field(i).SetInt(resultInt) 122 case reflect.Slice: 123 switch v.Field(i).Type().Elem().Kind() { 124 case reflect.String: 125 v.Field(i).Set(reflect.ValueOf([]string{result})) 126 case reflect.Int: 127 resultInt, err := strconv.ParseInt(result, 10, 0) 128 if err != nil { 129 return data, fmt.Errorf("invalid value for int: %w", err) 130 } 131 132 v.Field(i).Set(reflect.ValueOf([]int{int(resultInt)})) 133 } 134 default: 135 // skip any other types 136 continue 137 } 138 } 139 140 return data, nil 141 } 142 143 type proposalType struct { 144 Name string 145 MsgType string 146 Msg sdk.Msg 147 } 148 149 // Prompt the proposal type values and return the proposal and its metadata 150 func (p *proposalType) Prompt(cdc codec.Codec, skipMetadata bool) (*proposal, types.ProposalMetadata, error) { 151 metadata, err := PromptMetadata(skipMetadata) 152 if err != nil { 153 return nil, metadata, fmt.Errorf("failed to set proposal metadata: %w", err) 154 } 155 156 proposal := &proposal{ 157 Metadata: "ipfs://CID", // the metadata must be saved on IPFS, set placeholder 158 Title: metadata.Title, 159 Summary: metadata.Summary, 160 } 161 162 // set deposit 163 depositPrompt := promptui.Prompt{ 164 Label: "Enter proposal deposit", 165 Validate: client.ValidatePromptCoins, 166 } 167 proposal.Deposit, err = depositPrompt.Run() 168 if err != nil { 169 return nil, metadata, fmt.Errorf("failed to set proposal deposit: %w", err) 170 } 171 172 if p.Msg == nil { 173 return proposal, metadata, nil 174 } 175 176 // set messages field 177 result, err := Prompt(p.Msg, "msg") 178 if err != nil { 179 return nil, metadata, fmt.Errorf("failed to set proposal message: %w", err) 180 } 181 182 message, err := cdc.MarshalInterfaceJSON(result) 183 if err != nil { 184 return nil, metadata, fmt.Errorf("failed to marshal proposal message: %w", err) 185 } 186 proposal.Messages = append(proposal.Messages, message) 187 188 return proposal, metadata, nil 189 } 190 191 // getProposalSuggestions suggests a list of proposal types 192 func getProposalSuggestions() []string { 193 types := make([]string, len(suggestedProposalTypes)) 194 for i, p := range suggestedProposalTypes { 195 types[i] = p.Name 196 } 197 return types 198 } 199 200 // PromptMetadata prompts for proposal metadata or only title and summary if skip is true 201 func PromptMetadata(skip bool) (types.ProposalMetadata, error) { 202 if !skip { 203 metadata, err := Prompt(types.ProposalMetadata{}, "proposal") 204 if err != nil { 205 return metadata, fmt.Errorf("failed to set proposal metadata: %w", err) 206 } 207 208 return metadata, nil 209 } 210 211 // prompt for title and summary 212 titlePrompt := promptui.Prompt{ 213 Label: "Enter proposal title", 214 Validate: client.ValidatePromptNotEmpty, 215 } 216 217 title, err := titlePrompt.Run() 218 if err != nil { 219 return types.ProposalMetadata{}, fmt.Errorf("failed to set proposal title: %w", err) 220 } 221 222 summaryPrompt := promptui.Prompt{ 223 Label: "Enter proposal summary", 224 Validate: client.ValidatePromptNotEmpty, 225 } 226 227 summary, err := summaryPrompt.Run() 228 if err != nil { 229 return types.ProposalMetadata{}, fmt.Errorf("failed to set proposal summary: %w", err) 230 } 231 232 return types.ProposalMetadata{Title: title, Summary: summary}, nil 233 } 234 235 // NewCmdDraftProposal let a user generate a draft proposal. 236 func NewCmdDraftProposal() *cobra.Command { 237 flagSkipMetadata := "skip-metadata" 238 239 cmd := &cobra.Command{ 240 Use: "draft-proposal", 241 Short: "Generate a draft proposal json file. The generated proposal json contains only one message (skeleton).", 242 SilenceUsage: true, 243 RunE: func(cmd *cobra.Command, _ []string) error { 244 clientCtx, err := client.GetClientTxContext(cmd) 245 if err != nil { 246 return err 247 } 248 249 // prompt proposal type 250 proposalTypesPrompt := promptui.Select{ 251 Label: "Select proposal type", 252 Items: getProposalSuggestions(), 253 } 254 255 _, selectedProposalType, err := proposalTypesPrompt.Run() 256 if err != nil { 257 return fmt.Errorf("failed to prompt proposal types: %w", err) 258 } 259 260 var proposal proposalType 261 for _, p := range suggestedProposalTypes { 262 if strings.EqualFold(p.Name, selectedProposalType) { 263 proposal = p 264 break 265 } 266 } 267 268 // create any proposal type 269 if proposal.Name == proposalOther { 270 // prompt proposal type 271 msgPrompt := promptui.Select{ 272 Label: "Select proposal message type:", 273 Items: func() []string { 274 msgs := clientCtx.InterfaceRegistry.ListImplementations(sdk.MsgInterfaceProtoName) 275 sort.Strings(msgs) 276 return msgs 277 }(), 278 } 279 280 _, result, err := msgPrompt.Run() 281 if err != nil { 282 return fmt.Errorf("failed to prompt proposal types: %w", err) 283 } 284 285 proposal.MsgType = result 286 } 287 288 if proposal.MsgType != "" { 289 proposal.Msg, err = sdk.GetMsgFromTypeURL(clientCtx.Codec, proposal.MsgType) 290 if err != nil { 291 // should never happen 292 panic(err) 293 } 294 } 295 296 skipMetadataPrompt, _ := cmd.Flags().GetBool(flagSkipMetadata) 297 298 result, metadata, err := proposal.Prompt(clientCtx.Codec, skipMetadataPrompt) 299 if err != nil { 300 return err 301 } 302 303 if err := writeFile(draftProposalFileName, result); err != nil { 304 return err 305 } 306 307 if !skipMetadataPrompt { 308 if err := writeFile(draftMetadataFileName, metadata); err != nil { 309 return err 310 } 311 } 312 313 cmd.Println("The 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.") 314 315 return nil 316 }, 317 } 318 319 flags.AddTxFlagsToCmd(cmd) 320 cmd.Flags().Bool(flagSkipMetadata, false, "skip metadata prompt") 321 322 return cmd 323 } 324 325 // writeFile writes the input to the file 326 func writeFile(fileName string, input any) error { 327 raw, err := json.MarshalIndent(input, "", " ") 328 if err != nil { 329 return fmt.Errorf("failed to marshal proposal: %w", err) 330 } 331 332 if err := os.WriteFile(fileName, raw, 0o600); err != nil { 333 return err 334 } 335 336 return nil 337 }