mirror of
https://github.com/kaspanet/kaspad.git
synced 2025-06-05 13:46:42 +00:00
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.
This commit is contained in:
parent
cb9d7e313d
commit
90d4dbcba1
19
cmd/wallet/README.md
Normal file
19
cmd/wallet/README.md
Normal file
@ -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`
|
40
cmd/wallet/balance.go
Normal file
40
cmd/wallet/balance.go
Normal file
@ -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
|
||||
}
|
20
cmd/wallet/common.go
Normal file
20
cmd/wallet/common.go
Normal file
@ -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)
|
||||
}
|
85
cmd/wallet/config.go
Normal file
85
cmd/wallet/config.go
Normal file
@ -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
|
||||
}
|
37
cmd/wallet/create.go
Normal file
37
cmd/wallet/create.go
Normal file
@ -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
|
||||
}
|
25
cmd/wallet/main.go
Normal file
25
cmd/wallet/main.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
201
cmd/wallet/send.go
Normal file
201
cmd/wallet/send.go
Normal file
@ -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
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user