Files

683 lines
16 KiB
Go

// Copyright (C) 2022, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package prompts
import (
"errors"
"fmt"
"math/big"
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/luxfi/cli/v2/pkg/constants"
"github.com/luxfi/cli/v2/pkg/models"
"github.com/luxfi/cli/v2/pkg/ux"
"github.com/luxfi/ids"
"github.com/luxfi/geth/common"
"github.com/manifoldco/promptui"
"golang.org/x/mod/semver"
)
const (
Yes = "Yes"
No = "No"
Add = "Add"
Del = "Delete"
Preview = "Preview"
MoreInfo = "More Info"
Done = "Done"
Cancel = "Cancel"
LessThanEq = "Less Than Or Eq"
MoreThanEq = "More Than Or Eq"
MoreThan = "More Than"
)
var errNoKeys = errors.New("no keys")
type Comparator struct {
Label string // Label that identifies reference value
Type string // Less Than Eq or More than Eq
Value uint64 // Value to Compare To
}
func (comparator *Comparator) Validate(val uint64) error {
switch comparator.Type {
case LessThanEq:
if val > comparator.Value {
return fmt.Errorf("the value must be smaller than or equal to %s (%d)", comparator.Label, comparator.Value)
}
case MoreThan:
if val <= comparator.Value {
return fmt.Errorf("the value must be bigger than %s (%d)", comparator.Label, comparator.Value)
}
case MoreThanEq:
if val < comparator.Value {
return fmt.Errorf("the value must be bigger than or equal to %s (%d)", comparator.Label, comparator.Value)
}
}
return nil
}
type Prompter interface {
CapturePositiveBigInt(promptStr string) (*big.Int, error)
CaptureAddress(promptStr string) (common.Address, error)
CaptureNewFilepath(promptStr string) (string, error)
CaptureExistingFilepath(promptStr string) (string, error)
CaptureYesNo(promptStr string) (bool, error)
CaptureNoYes(promptStr string) (bool, error)
CaptureList(promptStr string, options []string) (string, error)
CaptureString(promptStr string) (string, error)
CaptureGitURL(promptStr string) (*url.URL, error)
CaptureStringAllowEmpty(promptStr string) (string, error)
CaptureEmail(promptStr string) (string, error)
CaptureIndex(promptStr string, options []any) (int, error)
CaptureVersion(promptStr string) (string, error)
CaptureDuration(promptStr string) (time.Duration, error)
CaptureDate(promptStr string) (time.Time, error)
CaptureNodeID(promptStr string) (ids.NodeID, error)
CaptureID(promptStr string) (ids.ID, error)
CaptureWeight(promptStr string) (uint64, error)
CapturePositiveInt(promptStr string, comparators []Comparator) (int, error)
CaptureUint64(promptStr string) (uint64, error)
CaptureUint64Compare(promptStr string, comparators []Comparator) (uint64, error)
CapturePChainAddress(promptStr string, network models.Network) (string, error)
CaptureFutureDate(promptStr string, minDate time.Time) (time.Time, error)
ChooseKeyOrLedger(goal string) (bool, error)
CaptureURL(promptStr string, validateConnection bool) (string, error)
}
type realPrompter struct{}
// NewProcessChecker creates a new process checker which can respond if the server is running
func NewPrompter() Prompter {
return &realPrompter{}
}
// CaptureListDecision runs a for loop and continuously asks the
// user for a specific input (currently only `CapturePChainAddress`
// and `CaptureAddress` is supported) until the user cancels or
// chooses `Done`. It does also offer an optional `info` to print
// (if provided) and a preview. Items can also be removed.
func CaptureListDecision[T comparable](
// we need this in order to be able to run mock tests
prompter Prompter,
// the main prompt for entering address keys
prompt string,
// the Capture function to use
capture func(prompt string) (T, error),
// the prompt for each address
capturePrompt string,
// label describes the entity we are prompting for (e.g. address, control key, etc.)
label string,
// optional parameter to allow the user to print the info string for more information
info string,
) ([]T, bool, error) {
finalList := []T{}
for {
listDecision, err := prompter.CaptureList(
prompt, []string{Add, Del, Preview, MoreInfo, Done, Cancel},
)
if err != nil {
return nil, false, err
}
switch listDecision {
case Add:
elem, err := capture(capturePrompt)
if err != nil {
return nil, false, err
}
if contains(finalList, elem) {
fmt.Println(label + " already in list")
continue
}
finalList = append(finalList, elem)
case Del:
if len(finalList) == 0 {
fmt.Println("No " + label + " added yet")
continue
}
finalListAnyT := []any{}
for _, v := range finalList {
finalListAnyT = append(finalListAnyT, v)
}
index, err := prompter.CaptureIndex("Choose element to remove:", finalListAnyT)
if err != nil {
return nil, false, err
}
finalList = append(finalList[:index], finalList[index+1:]...)
case Preview:
if len(finalList) == 0 {
fmt.Println("The list is empty")
break
}
for i, k := range finalList {
fmt.Printf("%d. %v\n", i, k)
}
case MoreInfo:
if info != "" {
fmt.Println(info)
}
case Done:
return finalList, false, nil
case Cancel:
return nil, true, nil
default:
return nil, false, errors.New("unexpected option")
}
}
}
func (*realPrompter) CaptureDuration(promptStr string) (time.Duration, error) {
prompt := promptui.Prompt{
Label: promptStr,
Validate: validateStakingDuration,
}
durationStr, err := prompt.Run()
if err != nil {
return 0, err
}
return time.ParseDuration(durationStr)
}
func (*realPrompter) CaptureDate(promptStr string) (time.Time, error) {
prompt := promptui.Prompt{
Label: promptStr,
Validate: validateTime,
}
timeStr, err := prompt.Run()
if err != nil {
return time.Time{}, err
}
return time.Parse(constants.TimeParseLayout, timeStr)
}
func (*realPrompter) CaptureID(promptStr string) (ids.ID, error) {
prompt := promptui.Prompt{
Label: promptStr,
Validate: validateID,
}
idStr, err := prompt.Run()
if err != nil {
return ids.Empty, err
}
return ids.FromString(idStr)
}
func (*realPrompter) CaptureNodeID(promptStr string) (ids.NodeID, error) {
prompt := promptui.Prompt{
Label: promptStr,
Validate: validateNodeID,
}
nodeIDStr, err := prompt.Run()
if err != nil {
return ids.EmptyNodeID, err
}
return ids.NodeIDFromString(nodeIDStr)
}
func (*realPrompter) CaptureWeight(promptStr string) (uint64, error) {
prompt := promptui.Prompt{
Label: promptStr,
Validate: validateWeight,
}
amountStr, err := prompt.Run()
if err != nil {
return 0, err
}
return strconv.ParseUint(amountStr, 10, 64)
}
func (*realPrompter) CaptureUint64(promptStr string) (uint64, error) {
prompt := promptui.Prompt{
Label: promptStr,
Validate: validateBiggerThanZero,
}
amountStr, err := prompt.Run()
if err != nil {
return 0, err
}
return strconv.ParseUint(amountStr, 0, 64)
}
func (*realPrompter) CapturePositiveInt(promptStr string, comparators []Comparator) (int, error) {
prompt := promptui.Prompt{
Label: promptStr,
Validate: func(input string) error {
val, err := strconv.Atoi(input)
if err != nil {
return err
}
if val < 0 {
return errors.New("input is less than 0")
}
for _, comparator := range comparators {
if err := comparator.Validate(uint64(val)); err != nil {
return err
}
}
return nil
},
}
amountStr, err := prompt.Run()
if err != nil {
return 0, err
}
return strconv.Atoi(amountStr)
}
func (*realPrompter) CaptureUint64Compare(promptStr string, comparators []Comparator) (uint64, error) {
prompt := promptui.Prompt{
Label: promptStr,
Validate: func(input string) error {
val, err := strconv.ParseUint(input, 0, 64)
if err != nil {
return err
}
for _, comparator := range comparators {
if err := comparator.Validate(val); err != nil {
return err
}
}
return nil
},
}
amountStr, err := prompt.Run()
if err != nil {
return 0, err
}
return strconv.ParseUint(amountStr, 0, 64)
}
func (*realPrompter) CapturePositiveBigInt(promptStr string) (*big.Int, error) {
prompt := promptui.Prompt{
Label: promptStr,
Validate: validatePositiveBigInt,
}
amountStr, err := prompt.Run()
if err != nil {
return nil, err
}
amountInt := new(big.Int)
amountInt, ok := amountInt.SetString(amountStr, 10)
if !ok {
return nil, errors.New("SetString: error")
}
return amountInt, nil
}
func (*realPrompter) CapturePChainAddress(promptStr string, network models.Network) (string, error) {
prompt := promptui.Prompt{
Label: promptStr,
Validate: getPChainValidationFunc(network),
}
return prompt.Run()
}
func (*realPrompter) CaptureAddress(promptStr string) (common.Address, error) {
prompt := promptui.Prompt{
Label: promptStr,
Validate: validateAddress,
}
addressStr, err := prompt.Run()
if err != nil {
return common.Address{}, err
}
addressHex := common.HexToAddress(addressStr)
return addressHex, nil
}
func (*realPrompter) CaptureExistingFilepath(promptStr string) (string, error) {
prompt := promptui.Prompt{
Label: promptStr,
Validate: validateExistingFilepath,
}
pathStr, err := prompt.Run()
if err != nil {
return "", err
}
return pathStr, nil
}
func (*realPrompter) CaptureNewFilepath(promptStr string) (string, error) {
prompt := promptui.Prompt{
Label: promptStr,
Validate: validateNewFilepath,
}
pathStr, err := prompt.Run()
if err != nil {
return "", err
}
return pathStr, nil
}
func yesNoBase(promptStr string, orderedOptions []string) (bool, error) {
prompt := promptui.Select{
Label: promptStr,
Items: orderedOptions,
}
_, decision, err := prompt.Run()
if err != nil {
return false, err
}
return decision == Yes, nil
}
func (*realPrompter) CaptureYesNo(promptStr string) (bool, error) {
return yesNoBase(promptStr, []string{Yes, No})
}
func (*realPrompter) CaptureNoYes(promptStr string) (bool, error) {
return yesNoBase(promptStr, []string{No, Yes})
}
func (*realPrompter) CaptureList(promptStr string, options []string) (string, error) {
prompt := promptui.Select{
Label: promptStr,
Items: options,
}
_, listDecision, err := prompt.Run()
if err != nil {
return "", err
}
return listDecision, nil
}
func (*realPrompter) CaptureEmail(promptStr string) (string, error) {
prompt := promptui.Prompt{
Label: promptStr,
Validate: validateEmail,
}
str, err := prompt.Run()
if err != nil {
return "", err
}
return str, nil
}
func (*realPrompter) CaptureStringAllowEmpty(promptStr string) (string, error) {
prompt := promptui.Prompt{
Label: promptStr,
}
str, err := prompt.Run()
if err != nil {
return "", err
}
return str, nil
}
func (*realPrompter) CaptureString(promptStr string) (string, error) {
prompt := promptui.Prompt{
Label: promptStr,
Validate: func(input string) error {
if input == "" {
return errors.New("string cannot be empty")
}
return nil
},
}
str, err := prompt.Run()
if err != nil {
return "", err
}
return str, nil
}
func (*realPrompter) CaptureGitURL(promptStr string) (*url.URL, error) {
prompt := promptui.Prompt{
Label: promptStr,
Validate: validateURL,
}
str, err := prompt.Run()
if err != nil {
return nil, err
}
parsedURL, err := url.ParseRequestURI(str)
if err != nil {
return nil, err
}
return parsedURL, nil
}
func (*realPrompter) CaptureVersion(promptStr string) (string, error) {
prompt := promptui.Prompt{
Label: promptStr,
Validate: func(input string) error {
if !semver.IsValid(input) {
return errors.New("version must be a legal semantic version (ex: v1.1.1)")
}
return nil
},
}
str, err := prompt.Run()
if err != nil {
return "", err
}
return str, nil
}
func (*realPrompter) CaptureIndex(promptStr string, options []any) (int, error) {
prompt := promptui.Select{
Label: promptStr,
Items: options,
}
listIndex, _, err := prompt.Run()
if err != nil {
return 0, err
}
return listIndex, nil
}
// CaptureFutureDate requires from the user a date input which is in the future.
// If `minDate` is not empty, the minimum time in the future from the provided date is required
// Otherwise, time from time.Now() is chosen.
func (*realPrompter) CaptureFutureDate(promptStr string, minDate time.Time) (time.Time, error) {
prompt := promptui.Prompt{
Label: promptStr,
Validate: func(s string) error {
t, err := time.Parse(constants.TimeParseLayout, s)
if err != nil {
return err
}
if minDate == (time.Time{}) {
minDate = time.Now()
}
if t.Before(minDate.UTC()) {
return fmt.Errorf("the provided date is before %s UTC", minDate.Format(constants.TimeParseLayout))
}
return nil
},
}
timestampStr, err := prompt.Run()
if err != nil {
return time.Time{}, err
}
return time.Parse(constants.TimeParseLayout, timestampStr)
}
// returns true [resp. false] if user chooses stored key [resp. ledger] option
func (prompter *realPrompter) ChooseKeyOrLedger(goal string) (bool, error) {
const (
keyOption = "Use stored key"
ledgerOption = "Use ledger"
)
option, err := prompter.CaptureList(
fmt.Sprintf("Which key source should be used to %s?", goal),
[]string{keyOption, ledgerOption},
)
if err != nil {
return false, err
}
return option == keyOption, nil
}
func contains[T comparable](list []T, element T) bool {
for _, val := range list {
if val == element {
return true
}
}
return false
}
func getIndexInSlice[T comparable](list []T, element T) (int, error) {
for i, val := range list {
if val == element {
return i, nil
}
}
return 0, fmt.Errorf("element not found")
}
// check subnet authorization criteria:
// - [subnetAuthKeys] satisfy subnet's [threshold]
// - [subnetAuthKeys] is a subset of subnet's [controlKeys]
func CheckSubnetAuthKeys(subnetAuthKeys []string, controlKeys []string, threshold uint32) error {
if len(subnetAuthKeys) != int(threshold) {
return fmt.Errorf("number of given subnet auth differs from the threshold")
}
for _, subnetAuthKey := range subnetAuthKeys {
found := false
for _, controlKey := range controlKeys {
if subnetAuthKey == controlKey {
found = true
break
}
}
if !found {
return fmt.Errorf("subnet auth key %s does not belong to control keys", subnetAuthKey)
}
}
return nil
}
// get subnet authorization keys from the user, as a subset of the subnet's [controlKeys]
// with a len equal to the subnet's [threshold]
func GetSubnetAuthKeys(prompt Prompter, controlKeys []string, threshold uint32) ([]string, error) {
if len(controlKeys) == int(threshold) {
return controlKeys, nil
}
subnetAuthKeys := []string{}
filteredControlKeys := []string{}
filteredControlKeys = append(filteredControlKeys, controlKeys...)
for len(subnetAuthKeys) != int(threshold) {
subnetAuthKey, err := prompt.CaptureList(
"Choose a subnet auth key",
filteredControlKeys,
)
if err != nil {
return nil, err
}
index, err := getIndexInSlice(filteredControlKeys, subnetAuthKey)
if err != nil {
return nil, err
}
subnetAuthKeys = append(subnetAuthKeys, subnetAuthKey)
filteredControlKeys = append(filteredControlKeys[:index], filteredControlKeys[index+1:]...)
}
return subnetAuthKeys, nil
}
func GetTestnetKeyOrLedger(prompt Prompter, goal string, keyDir string) (bool, string, error) {
useStoredKey, err := prompt.ChooseKeyOrLedger(goal)
if err != nil {
return false, "", err
}
if !useStoredKey {
return true, "", nil
}
keyName, err := captureKeyName(prompt, goal, keyDir)
if err != nil {
if errors.Is(err, errNoKeys) {
ux.Logger.PrintToUser("No private keys have been found. Signing transactions on Testnet without a private key " +
"or ledger is not possible. Create a new one with `lux key create`, or use a ledger device.")
}
return false, "", err
}
return false, keyName, nil
}
func captureKeyName(prompt Prompter, goal string, keyDir string) (string, error) {
files, err := os.ReadDir(keyDir)
if err != nil {
return "", err
}
if len(files) < 1 {
return "", errNoKeys
}
keys := []string{}
for _, f := range files {
if strings.HasSuffix(f.Name(), constants.KeySuffix) {
keys = append(keys, strings.TrimSuffix(f.Name(), constants.KeySuffix))
}
}
keyName, err := prompt.CaptureList(fmt.Sprintf("Which stored key should be used to %s?", goal), keys)
if err != nil {
return "", err
}
return keyName, nil
}
// CaptureURL prompts for a URL and optionally validates the connection
func (realPrompter) CaptureURL(promptStr string, validateConnection bool) (string, error) {
prompt := promptui.Prompt{
Label: promptStr,
Validate: ValidateURLFormat,
}
urlStr, err := prompt.Run()
if err != nil {
return "", err
}
if validateConnection {
// TODO: Add connection validation if needed
// For now, just validate the format
}
return urlStr, nil
}