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  }