From 90d4dbcba18b50098d54c31ce5cb2f763b09ef5a Mon Sep 17 00:00:00 2001 From: stasatdaglabs <39559713+stasatdaglabs@users.noreply.github.com> Date: Wed, 23 Dec 2020 09:41:48 +0200 Subject: [PATCH] Implement a simple CLI wallet (#1261) * Copy over the CLI wallet from Kasparov. * Fix trivial compilation errors. * Reimplement the balance command. * Extract isUTXOSpendable to a separate function. * Reimplement the send command. * Fix bad transaction ID parsing. * Add a missing newline in a log. * Don't use msgTx in send(). * Fix isUTXOSpendable not checking whether a UTXO is of a coinbase transaction. * Add --devnet, --testnet, etc. to command line flags. * In `create`, only print the public key of the active network. * Use coinbase maturity in isUTXOSpendable. * Add a readme. * Fix formatting in readme. --- cmd/wallet/README.md | 19 ++++ cmd/wallet/balance.go | 40 +++++++++ cmd/wallet/common.go | 20 +++++ cmd/wallet/config.go | 85 ++++++++++++++++++ cmd/wallet/create.go | 37 ++++++++ cmd/wallet/main.go | 25 ++++++ cmd/wallet/send.go | 201 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 427 insertions(+) create mode 100644 cmd/wallet/README.md create mode 100644 cmd/wallet/balance.go create mode 100644 cmd/wallet/common.go create mode 100644 cmd/wallet/config.go create mode 100644 cmd/wallet/create.go create mode 100644 cmd/wallet/main.go create mode 100644 cmd/wallet/send.go diff --git a/cmd/wallet/README.md b/cmd/wallet/README.md new file mode 100644 index 000000000..46fb1d98e --- /dev/null +++ b/cmd/wallet/README.md @@ -0,0 +1,19 @@ +WALLET +====== + +## IMPORTANT: + +### This software is for TESTING ONLY. Do NOT use it for handling real money. + +`wallet` is a simple, no-frills wallet software operated via the command line.\ +It is capable of generating wallet key-pairs, printing a wallet's current balance, and sending simple transactions. + + +Usage +----- + +* Create a new wallet key-pair: `wallet create --testnet` +* Print a wallet's current balance: + `wallet balance --testnet --address=kaspatest:000000000000000000000000000000000000000000` +* Send funds to another wallet: + `wallet send --testnet --private-key=0000000000000000000000000000000000000000000000000000000000000000 --send-amount=50 --to-address=kaspatest:000000000000000000000000000000000000000000` \ No newline at end of file diff --git a/cmd/wallet/balance.go b/cmd/wallet/balance.go new file mode 100644 index 000000000..8c0d152fb --- /dev/null +++ b/cmd/wallet/balance.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "github.com/kaspanet/kaspad/infrastructure/network/rpcclient" + + "github.com/kaspanet/kaspad/util" +) + +func balance(conf *balanceConfig) error { + client, err := rpcclient.NewRPCClient(conf.RPCServer) + if err != nil { + return err + } + getUTXOsByAddressesResponse, err := client.GetUTXOsByAddresses([]string{conf.Address}) + if err != nil { + return err + } + virtualSelectedParentBlueScoreResponse, err := client.GetVirtualSelectedParentBlueScore() + if err != nil { + return err + } + virtualSelectedParentBlueScore := virtualSelectedParentBlueScoreResponse.BlueScore + + var availableBalance, pendingBalance uint64 + for _, entry := range getUTXOsByAddressesResponse.Entries { + if isUTXOSpendable(entry, virtualSelectedParentBlueScore, conf.ActiveNetParams.BlockCoinbaseMaturity) { + availableBalance += entry.UTXOEntry.Amount + } else { + pendingBalance += entry.UTXOEntry.Amount + } + } + + fmt.Printf("Balance:\t\tKAS %f\n", float64(availableBalance)/util.SompiPerKaspa) + if pendingBalance > 0 { + fmt.Printf("Pending balance:\tKAS %f\n", float64(pendingBalance)/util.SompiPerKaspa) + } + + return nil +} diff --git a/cmd/wallet/common.go b/cmd/wallet/common.go new file mode 100644 index 000000000..df98f193f --- /dev/null +++ b/cmd/wallet/common.go @@ -0,0 +1,20 @@ +package main + +import ( + "fmt" + "github.com/kaspanet/kaspad/app/appmessage" + "os" +) + +func isUTXOSpendable(entry *appmessage.UTXOsByAddressesEntry, virtualSelectedParentBlueScore uint64, coinbaseMaturity uint64) bool { + if !entry.UTXOEntry.IsCoinbase { + return true + } + blockBlueScore := entry.UTXOEntry.BlockBlueScore + return blockBlueScore+coinbaseMaturity < virtualSelectedParentBlueScore +} + +func printErrorAndExit(err error) { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(1) +} diff --git a/cmd/wallet/config.go b/cmd/wallet/config.go new file mode 100644 index 000000000..4571ee623 --- /dev/null +++ b/cmd/wallet/config.go @@ -0,0 +1,85 @@ +package main + +import ( + "github.com/kaspanet/kaspad/infrastructure/config" + "github.com/pkg/errors" + "os" + + "github.com/jessevdk/go-flags" +) + +const ( + createSubCmd = "create" + balanceSubCmd = "balance" + sendSubCmd = "send" +) + +type createConfig struct { + config.NetworkFlags +} + +type balanceConfig struct { + RPCServer string `long:"rpcserver" short:"s" description:"RPC server to connect to"` + Address string `long:"address" short:"d" description:"The public address to check the balance of" required:"true"` + config.NetworkFlags +} + +type sendConfig struct { + RPCServer string `long:"rpcserver" short:"s" description:"RPC server to connect to"` + PrivateKey string `long:"private-key" short:"k" description:"The private key of the sender (encoded in hex)" required:"true"` + ToAddress string `long:"to-address" short:"t" description:"The public address to send Kaspa to" required:"true"` + SendAmount float64 `long:"send-amount" short:"v" description:"An amount to send in Kaspa (e.g. 1234.12345678)" required:"true"` + config.NetworkFlags +} + +func parseCommandLine() (subCommand string, config interface{}) { + cfg := &struct{}{} + parser := flags.NewParser(cfg, flags.PrintErrors|flags.HelpFlag) + + createConf := &createConfig{} + parser.AddCommand(createSubCmd, "Creates a new wallet", + "Creates a private key and 3 public addresses, one for each of MainNet, TestNet and DevNet", createConf) + + balanceConf := &balanceConfig{} + parser.AddCommand(balanceSubCmd, "Shows the balance of a public address", + "Shows the balance for a public address in Kaspa", balanceConf) + + sendConf := &sendConfig{} + parser.AddCommand(sendSubCmd, "Sends a Kaspa transaction to a public address", + "Sends a Kaspa transaction to a public address", sendConf) + + _, err := parser.Parse() + + if err != nil { + var flagsErr *flags.Error + if ok := errors.As(err, &flagsErr); ok && flagsErr.Type == flags.ErrHelp { + os.Exit(0) + } else { + os.Exit(1) + } + return "", nil + } + + switch parser.Command.Active.Name { + case createSubCmd: + err := createConf.ResolveNetwork(parser) + if err != nil { + printErrorAndExit(err) + } + config = createConf + case balanceSubCmd: + err := balanceConf.ResolveNetwork(parser) + if err != nil { + printErrorAndExit(err) + } + config = balanceConf + case sendSubCmd: + err := sendConf.ResolveNetwork(parser) + if err != nil { + printErrorAndExit(err) + } + config = sendConf + } + + return parser.Command.Active.Name, config +} diff --git a/cmd/wallet/create.go b/cmd/wallet/create.go new file mode 100644 index 000000000..99edebd30 --- /dev/null +++ b/cmd/wallet/create.go @@ -0,0 +1,37 @@ +package main + +import ( + "fmt" + + "github.com/kaspanet/go-secp256k1" + "github.com/kaspanet/kaspad/util" + "github.com/pkg/errors" +) + +func create(conf *createConfig) error { + privateKey, err := secp256k1.GeneratePrivateKey() + if err != nil { + return errors.Wrap(err, "Failed to generate private key") + } + + fmt.Println("This is your private key, granting access to all wallet funds. Keep it safe. Use it only when sending Kaspa.") + fmt.Printf("Private key (hex):\t%s\n\n", privateKey.SerializePrivateKey()) + + fmt.Println("This is your public address, where money is to be sent.") + publicKey, err := privateKey.SchnorrPublicKey() + if err != nil { + return errors.Wrap(err, "Failed to generate public key") + } + publicKeySerialized, err := publicKey.Serialize() + if err != nil { + return errors.Wrap(err, "Failed to serialize public key") + } + + addr, err := util.NewAddressPubKeyHashFromPublicKey(publicKeySerialized[:], conf.ActiveNetParams.Prefix) + if err != nil { + return errors.Wrap(err, "Failed to generate p2pkh address") + } + fmt.Printf("Address (%s):\t%s\n", conf.ActiveNetParams.Name, addr) + + return nil +} diff --git a/cmd/wallet/main.go b/cmd/wallet/main.go new file mode 100644 index 000000000..b84eec163 --- /dev/null +++ b/cmd/wallet/main.go @@ -0,0 +1,25 @@ +package main + +import ( + "github.com/pkg/errors" +) + +func main() { + subCmd, config := parseCommandLine() + + var err error + switch subCmd { + case createSubCmd: + err = create(config.(*createConfig)) + case balanceSubCmd: + err = balance(config.(*balanceConfig)) + case sendSubCmd: + err = send(config.(*sendConfig)) + default: + err = errors.Errorf("Unknown sub-command '%s'\n", subCmd) + } + + if err != nil { + printErrorAndExit(err) + } +} diff --git a/cmd/wallet/send.go b/cmd/wallet/send.go new file mode 100644 index 000000000..b757cbaf2 --- /dev/null +++ b/cmd/wallet/send.go @@ -0,0 +1,201 @@ +package main + +import ( + "encoding/hex" + "fmt" + "github.com/kaspanet/go-secp256k1" + "github.com/kaspanet/kaspad/app/appmessage" + "github.com/kaspanet/kaspad/domain/consensus/model/externalapi" + "github.com/kaspanet/kaspad/domain/consensus/utils/constants" + "github.com/kaspanet/kaspad/domain/consensus/utils/subnetworks" + "github.com/kaspanet/kaspad/domain/consensus/utils/transactionid" + "github.com/kaspanet/kaspad/domain/consensus/utils/txscript" + "github.com/kaspanet/kaspad/infrastructure/network/rpcclient" + "github.com/kaspanet/kaspad/util" + "github.com/pkg/errors" +) + +const feeSompis uint64 = 1000 + +func send(conf *sendConfig) error { + toAddress, err := util.DecodeAddress(conf.ToAddress, conf.ActiveNetParams.Prefix) + if err != nil { + return err + } + + keyPair, publicKey, err := parsePrivateKey(conf.PrivateKey) + if err != nil { + return err + } + + serializedPublicKey, err := publicKey.Serialize() + if err != nil { + return err + } + fromAddress, err := util.NewAddressPubKeyHashFromPublicKey(serializedPublicKey[:], conf.ActiveNetParams.Prefix) + if err != nil { + return err + } + + client, err := rpcclient.NewRPCClient(conf.RPCServer) + if err != nil { + return err + } + utxos, err := fetchSpendableUTXOs(conf, client, fromAddress.String()) + if err != nil { + return err + } + + sendAmountSompi := uint64(conf.SendAmount * util.SompiPerKaspa) + totalToSend := sendAmountSompi + feeSompis + + selectedUTXOs, changeSompi, err := selectUTXOs(utxos, totalToSend) + if err != nil { + return err + } + + rpcTransaction, err := generateTransaction(keyPair, selectedUTXOs, sendAmountSompi, changeSompi, toAddress, fromAddress) + if err != nil { + return err + } + + transactionID, err := sendTransaction(client, rpcTransaction) + if err != nil { + return err + } + + fmt.Println("Transaction was sent successfully") + fmt.Printf("Transaction ID: \t%s\n", transactionID) + + return nil +} + +func parsePrivateKey(privateKeyHex string) (*secp256k1.SchnorrKeyPair, *secp256k1.SchnorrPublicKey, error) { + privateKeyBytes, err := hex.DecodeString(privateKeyHex) + if err != nil { + return nil, nil, errors.Wrap(err, "Error parsing private key hex") + } + keyPair, err := secp256k1.DeserializePrivateKeyFromSlice(privateKeyBytes) + if err != nil { + return nil, nil, errors.Wrap(err, "Error deserializing private key") + } + publicKey, err := keyPair.SchnorrPublicKey() + if err != nil { + return nil, nil, errors.Wrap(err, "Error generating public key") + } + return keyPair, publicKey, nil +} + +func fetchSpendableUTXOs(conf *sendConfig, client *rpcclient.RPCClient, address string) ([]*appmessage.UTXOsByAddressesEntry, error) { + getUTXOsByAddressesResponse, err := client.GetUTXOsByAddresses([]string{address}) + if err != nil { + return nil, err + } + virtualSelectedParentBlueScoreResponse, err := client.GetVirtualSelectedParentBlueScore() + if err != nil { + return nil, err + } + virtualSelectedParentBlueScore := virtualSelectedParentBlueScoreResponse.BlueScore + + spendableUTXOs := make([]*appmessage.UTXOsByAddressesEntry, 0) + for _, entry := range getUTXOsByAddressesResponse.Entries { + if !isUTXOSpendable(entry, virtualSelectedParentBlueScore, conf.ActiveNetParams.BlockCoinbaseMaturity) { + continue + } + spendableUTXOs = append(spendableUTXOs, entry) + } + return spendableUTXOs, nil +} + +func selectUTXOs(utxos []*appmessage.UTXOsByAddressesEntry, totalToSpend uint64) ( + selectedUTXOs []*appmessage.UTXOsByAddressesEntry, changeSompi uint64, err error) { + + selectedUTXOs = []*appmessage.UTXOsByAddressesEntry{} + totalValue := uint64(0) + + for _, utxo := range utxos { + selectedUTXOs = append(selectedUTXOs, utxo) + totalValue += utxo.UTXOEntry.Amount + + if totalValue >= totalToSpend { + break + } + } + + if totalValue < totalToSpend { + return nil, 0, errors.Errorf("Insufficient funds for send: %f required, while only %f available", + float64(totalToSpend)/util.SompiPerKaspa, float64(totalValue)/util.SompiPerKaspa) + } + + return selectedUTXOs, totalValue - totalToSpend, nil +} + +func generateTransaction(keyPair *secp256k1.SchnorrKeyPair, selectedUTXOs []*appmessage.UTXOsByAddressesEntry, + sompisToSend uint64, change uint64, toAddress util.Address, + fromAddress util.Address) (*appmessage.RPCTransaction, error) { + + inputs := make([]*externalapi.DomainTransactionInput, len(selectedUTXOs)) + for i, utxo := range selectedUTXOs { + outpointTransactionIDBytes, err := hex.DecodeString(utxo.Outpoint.TransactionID) + if err != nil { + return nil, err + } + outpointTransactionID, err := transactionid.FromBytes(outpointTransactionIDBytes) + if err != nil { + return nil, err + } + outpoint := externalapi.DomainOutpoint{ + TransactionID: *outpointTransactionID, + Index: utxo.Outpoint.Index, + } + inputs[i] = &externalapi.DomainTransactionInput{PreviousOutpoint: outpoint} + } + + toScript, err := txscript.PayToAddrScript(toAddress) + if err != nil { + return nil, err + } + mainOutput := &externalapi.DomainTransactionOutput{ + Value: sompisToSend, + ScriptPublicKey: toScript, + } + fromScript, err := txscript.PayToAddrScript(fromAddress) + if err != nil { + return nil, err + } + changeOutput := &externalapi.DomainTransactionOutput{ + Value: change, + ScriptPublicKey: fromScript, + } + outputs := []*externalapi.DomainTransactionOutput{mainOutput, changeOutput} + + domainTransaction := &externalapi.DomainTransaction{ + Version: constants.TransactionVersion, + Inputs: inputs, + Outputs: outputs, + LockTime: 0, + SubnetworkID: subnetworks.SubnetworkIDNative, + Gas: 0, + Payload: nil, + PayloadHash: externalapi.DomainHash{}, + } + + for i, input := range domainTransaction.Inputs { + signatureScript, err := txscript.SignatureScript(domainTransaction, i, fromScript, txscript.SigHashAll, keyPair) + if err != nil { + return nil, err + } + input.SignatureScript = signatureScript + } + + rpcTransaction := appmessage.DomainTransactionToRPCTransaction(domainTransaction) + return rpcTransaction, nil +} + +func sendTransaction(client *rpcclient.RPCClient, rpcTransaction *appmessage.RPCTransaction) (string, error) { + submitTransactionResponse, err := client.SubmitTransaction(rpcTransaction) + if err != nil { + return "", errors.Wrapf(err, "error submitting transaction") + } + return submitTransactionResponse.TransactionID, nil +}