diff --git a/cmd/kaspawallet/config.go b/cmd/kaspawallet/config.go index 6313167bf..cd0ddb7a2 100644 --- a/cmd/kaspawallet/config.go +++ b/cmd/kaspawallet/config.go @@ -57,7 +57,7 @@ type sendConfig struct { DaemonAddress string `long:"daemonaddress" short:"d" description:"Wallet daemon server to connect to"` ToAddress string `long:"to-address" short:"t" description:"The public address to send Kaspa to" required:"true"` FromAddresses []string `long:"from-address" short:"a" description:"Specific public address to send Kaspa from. Use multiple times to accept several addresses" required:"false"` - SendAmount float64 `long:"send-amount" short:"v" description:"An amount to send in Kaspa (e.g. 1234.12345678)"` + SendAmount string `long:"send-amount" short:"v" description:"An amount to send in Kaspa (e.g. 1234.12345678)"` IsSendAll bool `long:"send-all" description:"Send all the Kaspa in the wallet (mutually exclusive with --send-amount)"` UseExistingChangeAddress bool `long:"use-existing-change-address" short:"u" description:"Will use an existing change address (in case no change address was ever used, it will use a new one)"` Verbose bool `long:"show-serialized" short:"s" description:"Show a list of hex encoded sent transactions"` @@ -74,7 +74,7 @@ type createUnsignedTransactionConfig struct { DaemonAddress string `long:"daemonaddress" short:"d" description:"Wallet daemon server to connect to"` ToAddress string `long:"to-address" short:"t" description:"The public address to send Kaspa to" required:"true"` FromAddresses []string `long:"from-address" short:"a" description:"Specific public address to send Kaspa from. Use multiple times to accept several addresses" required:"false"` - SendAmount float64 `long:"send-amount" short:"v" description:"An amount to send in Kaspa (e.g. 1234.12345678)"` + SendAmount string `long:"send-amount" short:"v" description:"An amount to send in Kaspa (e.g. 1234.12345678)"` IsSendAll bool `long:"send-all" description:"Send all the Kaspa in the wallet (mutually exclusive with --send-amount)"` UseExistingChangeAddress bool `long:"use-existing-change-address" short:"u" description:"Will use an existing change address (in case no change address was ever used, it will use a new one)"` config.NetworkFlags @@ -296,8 +296,8 @@ func parseCommandLine() (subCommand string, config interface{}) { } func validateCreateUnsignedTransactionConf(conf *createUnsignedTransactionConfig) error { - if (!conf.IsSendAll && conf.SendAmount == 0) || - (conf.IsSendAll && conf.SendAmount > 0) { + if (!conf.IsSendAll && conf.SendAmount == "") || + (conf.IsSendAll && conf.SendAmount != "") { return errors.New("exactly one of '--send-amount' or '--all' must be specified") } @@ -305,8 +305,8 @@ func validateCreateUnsignedTransactionConf(conf *createUnsignedTransactionConfig } func validateSendConfig(conf *sendConfig) error { - if (!conf.IsSendAll && conf.SendAmount == 0) || - (conf.IsSendAll && conf.SendAmount > 0) { + if (!conf.IsSendAll && conf.SendAmount == "") || + (conf.IsSendAll && conf.SendAmount != "") { return errors.New("exactly one of '--send-amount' or '--all' must be specified") } diff --git a/cmd/kaspawallet/create_unsigned_tx.go b/cmd/kaspawallet/create_unsigned_tx.go index 8f201685a..f213bc958 100644 --- a/cmd/kaspawallet/create_unsigned_tx.go +++ b/cmd/kaspawallet/create_unsigned_tx.go @@ -7,7 +7,7 @@ import ( "github.com/kaspanet/kaspad/cmd/kaspawallet/daemon/client" "github.com/kaspanet/kaspad/cmd/kaspawallet/daemon/pb" - "github.com/kaspanet/kaspad/domain/consensus/utils/constants" + "github.com/kaspanet/kaspad/cmd/kaspawallet/utils" ) func createUnsignedTransaction(conf *createUnsignedTransactionConfig) error { @@ -20,7 +20,12 @@ func createUnsignedTransaction(conf *createUnsignedTransactionConfig) error { ctx, cancel := context.WithTimeout(context.Background(), daemonTimeout) defer cancel() - sendAmountSompi := uint64(conf.SendAmount * constants.SompiPerKaspa) + sendAmountSompi, err := utils.KasToSompi(conf.SendAmount) + + if err != nil { + return err + } + response, err := daemonClient.CreateUnsignedTransactions(ctx, &pb.CreateUnsignedTransactionsRequest{ From: conf.FromAddresses, Address: conf.ToAddress, diff --git a/cmd/kaspawallet/send.go b/cmd/kaspawallet/send.go index 3e20c2b97..0ac8f78ab 100644 --- a/cmd/kaspawallet/send.go +++ b/cmd/kaspawallet/send.go @@ -10,7 +10,7 @@ import ( "github.com/kaspanet/kaspad/cmd/kaspawallet/daemon/pb" "github.com/kaspanet/kaspad/cmd/kaspawallet/keys" "github.com/kaspanet/kaspad/cmd/kaspawallet/libkaspawallet" - "github.com/kaspanet/kaspad/domain/consensus/utils/constants" + "github.com/kaspanet/kaspad/cmd/kaspawallet/utils" "github.com/pkg/errors" ) @@ -35,7 +35,11 @@ func send(conf *sendConfig) error { var sendAmountSompi uint64 if !conf.IsSendAll { - sendAmountSompi = uint64(conf.SendAmount * constants.SompiPerKaspa) + sendAmountSompi, err = utils.KasToSompi(conf.SendAmount) + + if err != nil { + return err + } } createUnsignedTransactionsResponse, err := diff --git a/cmd/kaspawallet/utils/format_kas.go b/cmd/kaspawallet/utils/format_kas.go index 5b1de0bea..e1f396dd5 100644 --- a/cmd/kaspawallet/utils/format_kas.go +++ b/cmd/kaspawallet/utils/format_kas.go @@ -2,8 +2,13 @@ package utils import ( "fmt" + "math" + "regexp" + "strconv" + "strings" "github.com/kaspanet/kaspad/domain/consensus/utils/constants" + "github.com/pkg/errors" ) // FormatKas takes the amount of sompis as uint64, and returns amount of KAS with 8 decimal places @@ -14,3 +19,50 @@ func FormatKas(amount uint64) string { } return res } + +// KasToSompi takes in a string representation of the Kas value to convert to Sompi +func KasToSompi(amount string) (uint64, error) { + err := validateKASAmountFormat(amount) + + if err != nil { + return 0, err + } + + // after validation, amount can only be either an int OR + // a float with an int component and decimal places + parts := strings.Split(amount, ".") + amountStr := "" + + if constants.SompiPerKaspa%10 != 0 { + return 0, errors.Errorf("Unable to convert to sompi when SompiPerKaspa is not a multiple of 10") + } + + decimalPlaces := int(math.Log10(constants.SompiPerKaspa)) + decimalStr := "" + + if len(parts) == 2 { + decimalStr = parts[1] + } + + amountStr = fmt.Sprintf("%s%-*s", parts[0], decimalPlaces, decimalStr) // Padded with spaces at the end to fill for missing decimals: Sample "0.01234 " + amountStr = strings.ReplaceAll(amountStr, " ", "0") // Make the spaces be 0s. Sample "0.012340000" + + convertedAmount, err := strconv.ParseUint(amountStr, 10, 64) + + return convertedAmount, err +} + +func validateKASAmountFormat(amount string) error { + // Check whether it's an integer, or a float with max 8 digits + match, err := regexp.MatchString("^([1-9]\\d{0,11}|0)(\\.\\d{0,8})?$", amount) + + if !match { + return errors.Errorf("Invalid amount") + } + + if err != nil { + return err + } + + return nil +} diff --git a/cmd/kaspawallet/utils/format_kas_test.go b/cmd/kaspawallet/utils/format_kas_test.go new file mode 100644 index 000000000..11646e942 --- /dev/null +++ b/cmd/kaspawallet/utils/format_kas_test.go @@ -0,0 +1,90 @@ +package utils + +import "testing" + +// Takes in a string representation of the Kas value to convert to Sompi +func TestKasToSompi(t *testing.T) { + type testVector struct { + originalAmount string + convertedAmount uint64 + } + + validCases := []testVector{ + {originalAmount: "0", convertedAmount: 0}, + {originalAmount: "1", convertedAmount: 100000000}, + {originalAmount: "33184.1489732", convertedAmount: 3318414897320}, + {originalAmount: "21.35808032", convertedAmount: 2135808032}, + {originalAmount: "184467440737.09551615", convertedAmount: 18446744073709551615}, + } + + for _, currentTestVector := range validCases { + convertedAmount, err := KasToSompi(currentTestVector.originalAmount) + + if err != nil { + t.Error(err) + } else if convertedAmount != currentTestVector.convertedAmount { + t.Errorf("Expected %s, to convert to %d. Got: %d", currentTestVector.originalAmount, currentTestVector.convertedAmount, convertedAmount) + } + } + + invalidCases := []string{ + "184467440737.09551616", // Bigger than max uint64 + "-1", + "a", + "", + } + + for _, currentTestVector := range invalidCases { + _, err := KasToSompi(currentTestVector) + + if err == nil { + t.Errorf("Expected an error but succeeded validation for test case %s", currentTestVector) + } + } +} + +func TestValidateAmountFormat(t *testing.T) { + validCases := []string{ + "0", + "1", + "1.0", + "0.1", + "0.12345678", + "111111111111.11111111", // 12 digits to the left of decimal, 8 digits to the right + "184467440737.09551615", // Maximum input that can be represented in sompi later + "184467440737.09551616", // Cannot be represented in sompi, but we'll acccept for "correct format" + "999999999999.99999999", // Cannot be represented in sompi, but we'll acccept for "correct format" + } + + for _, testCase := range validCases { + err := validateKASAmountFormat(testCase) + + if err != nil { + t.Error(err) + } + } + + invalidCases := []string{ + "", + "a", + "-1", + "0.123456789", // 9 decimal digits + ".1", // decimal but no integer component + "0a", // Extra character + "0000000000000", // 13 zeros + "012", // Int padded with zero + "00.1", // Decimal padded with zeros + "111111111111111111111", // all digits + "111111111111A11111111", // non-period/non-digit where decimal would be + "000000000000.00000000", // all zeros + "kaspa", // all text + } + + for _, testCase := range invalidCases { + err := validateKASAmountFormat(testCase) + + if err == nil { + t.Errorf("Expected an error but succeeded validation for test case %s", testCase) + } + } +}