mirror of
https://github.com/luxfi/cli.git
synced 2026-10-30 17:01:20 +00:00
Per canonical rule: Lux is its own thing, fuji isn't ours. Sweep removes positive references to the Avalanche testnet name from comments, function names, test fixtures, and example data. Where defensive code rejects non-Lux HRPs (lux/genesis), the test input swapped from 'P-fuji1...' to 'P-avax1...' so the rejection behavior still proves out without naming fuji as a thing. Build clean.
1245 lines
31 KiB
Go
1245 lines
31 KiB
Go
// Copyright (C) 2022-2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package prompts
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/luxfi/cli/pkg/ux"
|
|
"github.com/luxfi/constants"
|
|
"github.com/luxfi/crypto/common"
|
|
"github.com/luxfi/ids"
|
|
"github.com/luxfi/sdk/models"
|
|
"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"
|
|
|
|
// Address formats
|
|
PChainFormat = "P-Chain"
|
|
CChainFormat = "C-Chain"
|
|
)
|
|
|
|
var errNoKeys = errors.New("no keys")
|
|
|
|
// promptUIRunner is a variable for testing purposes to allow mocking prompt.Run()
|
|
var promptUIRunner = func(prompt promptui.Prompt) (string, error) {
|
|
return prompt.Run()
|
|
}
|
|
|
|
// promptUISelectRunner is a variable for testing purposes to allow mocking select.Run()
|
|
var promptUISelectRunner = func(sel promptui.Select) (int, string, error) {
|
|
return sel.Run()
|
|
}
|
|
|
|
// utilsReadLongString is a variable for testing purposes to allow mocking long string reads
|
|
var utilsReadLongString = func(msg string, args ...interface{}) (string, error) {
|
|
fmt.Printf(msg, args...)
|
|
reader := bufio.NewReader(os.Stdin)
|
|
longString, err := reader.ReadString('\n')
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
// Remove newline character at the end
|
|
longString = strings.TrimSuffix(longString, "\n")
|
|
return longString, nil
|
|
}
|
|
|
|
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)
|
|
CaptureURL(promptStr string, validateConnection bool) (string, 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, validator func(uint64) error) (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)
|
|
CaptureValidatorBalance(promptStr string, availableBalance float64, minBalance float64) (float64, error)
|
|
CaptureListWithSize(prompt string, options []string, size int) ([]string, error)
|
|
CaptureFloat(promptStr string, validator func(float64) error) (float64, error)
|
|
CaptureAddresses(promptStr string) ([]common.Address, error)
|
|
CaptureXChainAddress(promptStr string, network models.Network) (string, error)
|
|
CaptureValidatedString(promptStr string, validator func(string) error) (string, error)
|
|
CaptureRepoBranch(promptStr string, repo string) (string, error)
|
|
CaptureRepoFile(promptStr string, repo string, branch string) (string, error)
|
|
CaptureInt(promptStr string, validator func(int) error) (int, error)
|
|
CaptureUint8(promptStr string) (uint8, error)
|
|
CaptureTestnetDuration(promptStr string) (time.Duration, error)
|
|
CaptureMainnetDuration(promptStr string) (time.Duration, error)
|
|
CaptureMainnetL1StakingDuration(promptStr string) (time.Duration, 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: validateDuration,
|
|
}
|
|
|
|
durationStr, err := promptUIRunner(prompt)
|
|
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 := promptUIRunner(prompt)
|
|
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 := promptUIRunner(prompt)
|
|
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 := promptUIRunner(prompt)
|
|
if err != nil {
|
|
return ids.EmptyNodeID, err
|
|
}
|
|
return ids.NodeIDFromString(nodeIDStr)
|
|
}
|
|
|
|
func (*realPrompter) CaptureWeight(promptStr string, validator func(uint64) error) (uint64, error) {
|
|
prompt := promptui.Prompt{
|
|
Label: promptStr,
|
|
Validate: func(input string) error {
|
|
// First validate it's a valid weight
|
|
if err := validateWeight(input); err != nil {
|
|
return err
|
|
}
|
|
// Then apply custom validation if provided
|
|
if validator != nil {
|
|
val, err := strconv.ParseUint(input, 10, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return validator(val)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
amountStr, err := promptUIRunner(prompt)
|
|
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 := promptUIRunner(prompt)
|
|
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 := promptUIRunner(prompt)
|
|
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 := promptUIRunner(prompt)
|
|
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 := promptUIRunner(prompt)
|
|
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 promptUIRunner(prompt)
|
|
}
|
|
|
|
func (*realPrompter) CaptureAddress(promptStr string) (common.Address, error) {
|
|
prompt := promptui.Prompt{
|
|
Label: promptStr,
|
|
Validate: validateAddress,
|
|
}
|
|
|
|
addressStr, err := promptUIRunner(prompt)
|
|
if err != nil {
|
|
return common.Address{}, err
|
|
}
|
|
|
|
// Remove 0x prefix if present
|
|
addr := addressStr
|
|
if len(addressStr) >= 2 && addressStr[0:2] == "0x" {
|
|
addr = addressStr[2:]
|
|
}
|
|
b, _ := hex.DecodeString(addr)
|
|
addressHex := common.BytesToAddress(b)
|
|
return addressHex, nil
|
|
}
|
|
|
|
func (*realPrompter) CaptureExistingFilepath(promptStr string) (string, error) {
|
|
prompt := promptui.Prompt{
|
|
Label: promptStr,
|
|
Validate: validateExistingFilepath,
|
|
}
|
|
|
|
pathStr, err := promptUIRunner(prompt)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return pathStr, nil
|
|
}
|
|
|
|
func (*realPrompter) CaptureNewFilepath(promptStr string) (string, error) {
|
|
prompt := promptui.Prompt{
|
|
Label: promptStr,
|
|
Validate: validateNewFilepath,
|
|
}
|
|
|
|
pathStr, err := promptUIRunner(prompt)
|
|
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 := promptUISelectRunner(prompt)
|
|
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 := promptUISelectRunner(prompt)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return listDecision, nil
|
|
}
|
|
|
|
func (*realPrompter) CaptureEmail(promptStr string) (string, error) {
|
|
prompt := promptui.Prompt{
|
|
Label: promptStr,
|
|
Validate: validateEmail,
|
|
}
|
|
|
|
str, err := promptUIRunner(prompt)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return str, nil
|
|
}
|
|
|
|
func (*realPrompter) CaptureURL(promptStr string, validateConnection bool) (string, error) {
|
|
// Loop until we get a valid URL (with connection check if requested)
|
|
for {
|
|
prompt := promptui.Prompt{
|
|
Label: promptStr,
|
|
Validate: ValidateURLFormat,
|
|
}
|
|
|
|
urlStr, err := promptUIRunner(prompt)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Validate connection if requested
|
|
if validateConnection {
|
|
parsedURL, err := url.Parse(urlStr)
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid URL: %w", err)
|
|
}
|
|
|
|
// Try to connect to the URL
|
|
client := &http.Client{
|
|
Timeout: 5 * time.Second,
|
|
}
|
|
|
|
resp, err := client.Head(urlStr)
|
|
if err != nil {
|
|
// Try GET if HEAD fails
|
|
resp, err = client.Get(urlStr)
|
|
if err != nil {
|
|
// Connection failed, loop to prompt again
|
|
fmt.Printf("Failed to connect to %s: %v\n", parsedURL.Host, err)
|
|
continue
|
|
}
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
// Accept any successful response (2xx, 3xx)
|
|
if resp.StatusCode >= 400 {
|
|
// Bad status, loop to prompt again
|
|
fmt.Printf("URL returned error status %d\n", resp.StatusCode)
|
|
continue
|
|
}
|
|
}
|
|
|
|
return urlStr, nil
|
|
}
|
|
}
|
|
|
|
func (*realPrompter) CaptureStringAllowEmpty(promptStr string) (string, error) {
|
|
prompt := promptui.Prompt{
|
|
Label: promptStr,
|
|
}
|
|
|
|
str, err := promptUIRunner(prompt)
|
|
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 := promptUIRunner(prompt)
|
|
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 := promptUIRunner(prompt)
|
|
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 := promptUIRunner(prompt)
|
|
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 := promptUISelectRunner(prompt)
|
|
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.IsZero() {
|
|
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 := promptUIRunner(prompt)
|
|
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 should be used %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
|
|
}
|
|
|
|
// GetKeyOrLedger prompts user to choose between key or ledger
|
|
func GetKeyOrLedger(prompter Prompter, goal string, keyDir string) (bool, string, error) {
|
|
useStoredKey, err := prompter.ChooseKeyOrLedger(goal)
|
|
if err != nil {
|
|
return false, "", err
|
|
}
|
|
if !useStoredKey {
|
|
return true, "", nil
|
|
}
|
|
keyName, err := captureKeyName(prompter, goal, keyDir)
|
|
if err != nil {
|
|
return false, "", err
|
|
}
|
|
return false, keyName, nil
|
|
}
|
|
|
|
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 chain authorization criteria:
|
|
// - [chainAuthKeys] satisfy chain's [threshold]
|
|
// - [chainAuthKeys] is a subset of chain's [controlKeys]
|
|
func CheckChainAuthKeys(chainAuthKeys []string, controlKeys []string, threshold uint32) error {
|
|
if len(chainAuthKeys) != int(threshold) {
|
|
return fmt.Errorf("number of given chain auth differs from the threshold")
|
|
}
|
|
for _, chainAuthKey := range chainAuthKeys {
|
|
found := false
|
|
for _, controlKey := range controlKeys {
|
|
if chainAuthKey == controlKey {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return fmt.Errorf("chain auth key %s does not belong to control keys", chainAuthKey)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// get chain authorization keys from the user, as a subset of the chain's [controlKeys]
|
|
// with a len equal to the chain's [threshold]
|
|
func GetChainAuthKeys(prompt Prompter, controlKeys []string, threshold uint32) ([]string, error) {
|
|
if len(controlKeys) == int(threshold) {
|
|
return controlKeys, nil
|
|
}
|
|
chainAuthKeys := []string{}
|
|
filteredControlKeys := []string{}
|
|
filteredControlKeys = append(filteredControlKeys, controlKeys...)
|
|
for len(chainAuthKeys) != int(threshold) {
|
|
chainAuthKey, err := prompt.CaptureList(
|
|
"Choose a chain auth key",
|
|
filteredControlKeys,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
index, err := getIndexInSlice(filteredControlKeys, chainAuthKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
chainAuthKeys = append(chainAuthKeys, chainAuthKey)
|
|
filteredControlKeys = append(filteredControlKeys[:index], filteredControlKeys[index+1:]...)
|
|
}
|
|
return chainAuthKeys, 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) {
|
|
keyName := strings.TrimSuffix(f.Name(), constants.KeySuffix)
|
|
keys = append(keys, keyName)
|
|
}
|
|
}
|
|
|
|
keyName, err := prompt.CaptureList(fmt.Sprintf("Which stored key should be used to %s?", goal), keys)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return keyName, nil
|
|
}
|
|
|
|
func (*realPrompter) CaptureValidatorBalance(promptStr string, availableBalance float64, minBalance float64) (float64, error) {
|
|
prompt := promptui.Prompt{
|
|
Label: promptStr,
|
|
Validate: validateValidatorBalanceFunc(availableBalance, minBalance),
|
|
}
|
|
result, err := promptUIRunner(prompt)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return strconv.ParseFloat(result, 64)
|
|
}
|
|
|
|
// PromptChain prompts the user to select a chain
|
|
func PromptChain(
|
|
prompt Prompter,
|
|
message string,
|
|
blockchainNames []string,
|
|
pChainEnabled bool,
|
|
xChainEnabled bool,
|
|
cChainEnabled bool,
|
|
blockchainNameToAvoid string,
|
|
blockchainIDEnabled bool,
|
|
) (bool, bool, bool, bool, string, string, error) {
|
|
// Build options
|
|
options := []string{}
|
|
|
|
if pChainEnabled {
|
|
options = append(options, "P-Chain")
|
|
}
|
|
if xChainEnabled {
|
|
options = append(options, "X-Chain")
|
|
}
|
|
if cChainEnabled {
|
|
options = append(options, "C-Chain")
|
|
}
|
|
|
|
// Add blockchain names
|
|
for _, name := range blockchainNames {
|
|
if name != blockchainNameToAvoid {
|
|
options = append(options, name)
|
|
}
|
|
}
|
|
|
|
if blockchainIDEnabled {
|
|
options = append(options, "Enter blockchain ID")
|
|
}
|
|
|
|
options = append(options, Cancel)
|
|
|
|
choice, err := prompt.CaptureList(message, options)
|
|
if err != nil {
|
|
return false, false, false, false, "", "", err
|
|
}
|
|
|
|
if choice == Cancel {
|
|
return true, false, false, false, "", "", nil
|
|
}
|
|
|
|
// Return flags based on choice
|
|
pChain := choice == "P-Chain"
|
|
xChain := choice == "X-Chain"
|
|
cChain := choice == "C-Chain"
|
|
|
|
blockchainName := ""
|
|
blockchainID := ""
|
|
|
|
if choice == "Enter blockchain ID" {
|
|
blockchainID, err = prompt.CaptureString("Enter blockchain ID")
|
|
if err != nil {
|
|
return false, false, false, false, "", "", err
|
|
}
|
|
} else if !pChain && !xChain && !cChain {
|
|
// It's a blockchain name
|
|
blockchainName = choice
|
|
}
|
|
|
|
return false, pChain, xChain, cChain, blockchainName, blockchainID, nil
|
|
}
|
|
|
|
// CaptureKeyAddress prompts the user to select a key address
|
|
func CaptureKeyAddress(
|
|
prompt Prompter,
|
|
goal string,
|
|
keyDir string,
|
|
getKey func(string) (string, error),
|
|
network models.Network,
|
|
addressFormat string,
|
|
) (string, error) {
|
|
// Read available keys from keyDir
|
|
entries, err := os.ReadDir(keyDir)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
keys := []string{}
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".pk") {
|
|
keyName := strings.TrimSuffix(entry.Name(), ".pk")
|
|
keys = append(keys, keyName)
|
|
}
|
|
}
|
|
|
|
if len(keys) == 0 {
|
|
return "", errNoKeys
|
|
}
|
|
|
|
keyName, err := prompt.CaptureList(fmt.Sprintf("Which key should %s?", goal), keys)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
keyPath, err := getKey(keyName)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// For now, return the key path
|
|
// In a real implementation, this would convert the key to the appropriate address format
|
|
return keyPath, nil
|
|
}
|
|
|
|
// CaptureListWithSize allows selection of multiple items from a list
|
|
func (prompter *realPrompter) CaptureListWithSize(prompt string, options []string, size int) ([]string, error) {
|
|
if len(options) == 0 {
|
|
return nil, errors.New("no options provided")
|
|
}
|
|
|
|
selected := []string{}
|
|
remaining := make([]string, len(options))
|
|
copy(remaining, options)
|
|
|
|
for i := 0; i < size && len(remaining) > 0; i++ {
|
|
if i > 0 {
|
|
prompt = fmt.Sprintf("Select item %d of %d", i+1, size)
|
|
}
|
|
|
|
choice, err := prompter.CaptureList(prompt, append(remaining, Done))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if choice == Done {
|
|
break
|
|
}
|
|
|
|
selected = append(selected, choice)
|
|
// Remove selected item from remaining options
|
|
newRemaining := []string{}
|
|
for _, opt := range remaining {
|
|
if opt != choice {
|
|
newRemaining = append(newRemaining, opt)
|
|
}
|
|
}
|
|
remaining = newRemaining
|
|
}
|
|
|
|
return selected, nil
|
|
}
|
|
|
|
// CaptureFloat prompts the user for a floating point number
|
|
func (*realPrompter) CaptureFloat(promptStr string, validator func(float64) error) (float64, error) {
|
|
prompt := promptui.Prompt{
|
|
Label: promptStr,
|
|
Validate: func(input string) error {
|
|
val, err := strconv.ParseFloat(input, 64)
|
|
if err != nil {
|
|
return fmt.Errorf("strconv.ParseFloat: %w", err)
|
|
}
|
|
if validator != nil {
|
|
return validator(val)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
result, err := promptUIRunner(prompt)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return strconv.ParseFloat(result, 64)
|
|
}
|
|
|
|
func (*realPrompter) CaptureUint16(promptStr string) (uint16, error) {
|
|
prompt := promptui.Prompt{
|
|
Label: promptStr,
|
|
Validate: func(input string) error {
|
|
// Support both decimal and hex formats
|
|
base := 10
|
|
numStr := input
|
|
if strings.HasPrefix(input, "0x") || strings.HasPrefix(input, "0X") {
|
|
base = 16
|
|
numStr = input[2:]
|
|
}
|
|
val, err := strconv.ParseUint(numStr, base, 16)
|
|
if err != nil {
|
|
// Include strconv in the error message for tests
|
|
return fmt.Errorf("strconv.ParseUint: %w", err)
|
|
}
|
|
if val > 65535 {
|
|
return errors.New("value must be between 0 and 65535")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
result, err := promptUIRunner(prompt)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
// Parse with proper base detection
|
|
base := 10
|
|
numStr := result
|
|
if strings.HasPrefix(result, "0x") || strings.HasPrefix(result, "0X") {
|
|
base = 16
|
|
numStr = result[2:]
|
|
}
|
|
val, parseErr := strconv.ParseUint(numStr, base, 16)
|
|
if parseErr != nil {
|
|
// Return appropriate error message based on the error type
|
|
if strings.Contains(parseErr.Error(), "value out of range") {
|
|
return 0, errors.New("value out of range")
|
|
}
|
|
return 0, errors.New("invalid syntax")
|
|
}
|
|
return uint16(val), nil
|
|
}
|
|
|
|
func (*realPrompter) CaptureUint32(promptStr string) (uint32, error) {
|
|
prompt := promptui.Prompt{
|
|
Label: promptStr,
|
|
Validate: func(input string) error {
|
|
// Support both decimal and hex formats
|
|
base := 10
|
|
numStr := input
|
|
if strings.HasPrefix(input, "0x") || strings.HasPrefix(input, "0X") {
|
|
base = 16
|
|
numStr = input[2:]
|
|
}
|
|
_, err := strconv.ParseUint(numStr, base, 32)
|
|
if err != nil {
|
|
// Include strconv in the error message for tests
|
|
return fmt.Errorf("strconv.ParseUint: %w", err)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
result, err := promptUIRunner(prompt)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
// Support both decimal and hex formats for parsing the result
|
|
base := 10
|
|
numStr := result
|
|
if strings.HasPrefix(result, "0x") || strings.HasPrefix(result, "0X") {
|
|
base = 16
|
|
numStr = result[2:]
|
|
}
|
|
val, parseErr := strconv.ParseUint(numStr, base, 32)
|
|
if parseErr != nil {
|
|
// Return appropriate error message based on the error type
|
|
if strings.Contains(parseErr.Error(), "value out of range") {
|
|
return 0, errors.New("value out of range")
|
|
}
|
|
return 0, errors.New("invalid syntax")
|
|
}
|
|
return uint32(val), nil
|
|
}
|
|
|
|
// CaptureAddresses prompts for multiple addresses
|
|
func (*realPrompter) CaptureAddresses(promptStr string) ([]common.Address, error) {
|
|
for {
|
|
result, err := utilsReadLongString("%s", promptui.IconGood+" "+promptStr+" ")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Validate addresses
|
|
if err := validateAddresses(result); err != nil {
|
|
fmt.Printf("Invalid input: %v\n", err)
|
|
continue // Retry on validation failure
|
|
}
|
|
|
|
// Parse and return valid addresses
|
|
parts := strings.Split(result, ",")
|
|
addresses := make([]common.Address, 0, len(parts))
|
|
for _, part := range parts {
|
|
addr := strings.TrimSpace(part)
|
|
addresses = append(addresses, common.HexToAddress(addr))
|
|
}
|
|
|
|
return addresses, nil
|
|
}
|
|
}
|
|
|
|
// CaptureXChainAddress prompts for an X-Chain address
|
|
func (*realPrompter) CaptureXChainAddress(promptStr string, network models.Network) (string, error) {
|
|
prompt := promptui.Prompt{
|
|
Label: promptStr,
|
|
Validate: getXChainValidationFunc(network),
|
|
}
|
|
|
|
return promptUIRunner(prompt)
|
|
}
|
|
|
|
// CaptureValidatedString prompts for a string with custom validation
|
|
func (*realPrompter) CaptureValidatedString(promptStr string, validator func(string) error) (string, error) {
|
|
prompt := promptui.Prompt{
|
|
Label: promptStr,
|
|
Validate: validator,
|
|
}
|
|
|
|
return promptUIRunner(prompt)
|
|
}
|
|
|
|
// CaptureRepoBranch prompts for a git branch from a repository
|
|
func (*realPrompter) CaptureRepoBranch(promptStr string, repo string) (string, error) {
|
|
prompt := promptui.Prompt{
|
|
Label: promptStr,
|
|
Validate: ValidateRepoBranch,
|
|
}
|
|
|
|
return promptUIRunner(prompt)
|
|
}
|
|
|
|
// CaptureRepoFile prompts for a file path in a repository
|
|
func (*realPrompter) CaptureRepoFile(promptStr string, repo string, branch string) (string, error) {
|
|
prompt := promptui.Prompt{
|
|
Label: promptStr,
|
|
Validate: ValidateRepoFile,
|
|
}
|
|
|
|
return promptUIRunner(prompt)
|
|
}
|
|
|
|
// CaptureInt prompts for an integer with custom validation
|
|
func (*realPrompter) CaptureInt(promptStr string, validator func(int) error) (int, error) {
|
|
prompt := promptui.Prompt{
|
|
Label: promptStr,
|
|
Validate: func(input string) error {
|
|
val, err := strconv.Atoi(input)
|
|
if err != nil {
|
|
return fmt.Errorf("strconv.Atoi: %w", err)
|
|
}
|
|
if validator != nil {
|
|
return validator(val)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
result, err := promptUIRunner(prompt)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return strconv.Atoi(result)
|
|
}
|
|
|
|
// CaptureUint8 prompts for a uint8 value
|
|
func (*realPrompter) CaptureUint8(promptStr string) (uint8, error) {
|
|
prompt := promptui.Prompt{
|
|
Label: promptStr,
|
|
Validate: func(input string) error {
|
|
// Support decimal, hex, and octal formats
|
|
base := 10
|
|
numStr := input
|
|
if strings.HasPrefix(input, "0x") || strings.HasPrefix(input, "0X") {
|
|
base = 16
|
|
numStr = input[2:]
|
|
} else if strings.HasPrefix(input, "0") && len(input) > 1 && input != "0" {
|
|
base = 8
|
|
numStr = input[1:]
|
|
}
|
|
_, err := strconv.ParseUint(numStr, base, 8)
|
|
if err != nil {
|
|
return fmt.Errorf("strconv.ParseUint: %w", err)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
|
|
result, err := promptUIRunner(prompt)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
// Parse the result with the same logic
|
|
base := 10
|
|
numStr := result
|
|
if strings.HasPrefix(result, "0x") || strings.HasPrefix(result, "0X") {
|
|
base = 16
|
|
numStr = result[2:]
|
|
} else if strings.HasPrefix(result, "0") && len(result) > 1 && result != "0" {
|
|
base = 8
|
|
numStr = result[1:]
|
|
}
|
|
val, err := strconv.ParseUint(numStr, base, 64)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return uint8(val), nil //nolint:gosec // G115: Value validated to be within uint8 range
|
|
}
|
|
|
|
// CaptureTestnetDuration prompts for a staking duration on testnet
|
|
func (*realPrompter) CaptureTestnetDuration(promptStr string) (time.Duration, error) {
|
|
prompt := promptui.Prompt{
|
|
Label: promptStr,
|
|
Validate: validateTestnetStakingDuration,
|
|
}
|
|
|
|
durationStr, err := promptUIRunner(prompt)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return time.ParseDuration(durationStr)
|
|
}
|
|
|
|
// CaptureMainnetDuration prompts for a staking duration on mainnet
|
|
func (*realPrompter) CaptureMainnetDuration(promptStr string) (time.Duration, error) {
|
|
prompt := promptui.Prompt{
|
|
Label: promptStr,
|
|
Validate: validateMainnetStakingDuration,
|
|
}
|
|
|
|
durationStr, err := promptUIRunner(prompt)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return time.ParseDuration(durationStr)
|
|
}
|
|
|
|
// CaptureMainnetL1StakingDuration prompts for an L1 staking duration on mainnet
|
|
func (*realPrompter) CaptureMainnetL1StakingDuration(promptStr string) (time.Duration, error) {
|
|
prompt := promptui.Prompt{
|
|
Label: promptStr,
|
|
Validate: validateMainnetL1StakingDuration,
|
|
}
|
|
|
|
durationStr, err := promptUIRunner(prompt)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return time.ParseDuration(durationStr)
|
|
}
|