From b5ac64891e0c3b72dc1891068d58fe53fc78737e Mon Sep 17 00:00:00 2001 From: Jamie Holdstock Date: Fri, 24 Mar 2023 18:10:44 +0000 Subject: [PATCH] Add v3tool. (#366) This commit adds a new executable - v3tool - a developer testing tool which hits endpoints of the vspd API. --- cmd/v3tool/README.md | 21 ++++ cmd/v3tool/dcrwallet.go | 165 +++++++++++++++++++++++++++++++ cmd/v3tool/main.go | 208 ++++++++++++++++++++++++++++++++++++++++ go.mod | 1 + go.sum | 2 + 5 files changed, 397 insertions(+) create mode 100644 cmd/v3tool/README.md create mode 100644 cmd/v3tool/dcrwallet.go create mode 100644 cmd/v3tool/main.go diff --git a/cmd/v3tool/README.md b/cmd/v3tool/README.md new file mode 100644 index 0000000..51c62bc --- /dev/null +++ b/cmd/v3tool/README.md @@ -0,0 +1,21 @@ +# v3tool + +v3tool is a simple client for manual testing of vspd. +It is a developer tool, not suitable for end users or production use. + +## Prerequisites + +1. An instance of dcrwallet which owns at least one immature or live ticket. +1. An instance of vspd to test. + +## What v3tool does + +1. Retrieve the pubkey from vspd. +1. Retrieve the list of owned immature/live tickets from dcrwallet. +1. For each ticket: + 1. Use dcrwallet to find the tx hex, voting privkey and commitment address of the ticket. + 1. Get a fee address and amount from vspd to register this ticket. + 1. Create the fee tx and send it to vspd. + 1. Get the ticket status. + 1. Change vote choices on the ticket. + 1. Get the ticket status again. diff --git a/cmd/v3tool/dcrwallet.go b/cmd/v3tool/dcrwallet.go new file mode 100644 index 0000000..c5c8b6a --- /dev/null +++ b/cmd/v3tool/dcrwallet.go @@ -0,0 +1,165 @@ +package main + +import ( + "context" + "crypto/tls" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "strings" + + wallettypes "decred.org/dcrwallet/v2/rpc/jsonrpc/types" + "github.com/decred/dcrd/blockchain/stake/v4" + "github.com/decred/dcrd/chaincfg/v3" + "github.com/decred/dcrd/dcrutil/v4" + dcrdtypes "github.com/decred/dcrd/rpc/jsonrpc/types/v3" + "github.com/decred/dcrd/txscript/v4/stdaddr" + "github.com/decred/dcrd/txscript/v4/stdscript" + "github.com/decred/dcrd/wire" + "github.com/jrick/wsrpc/v2" +) + +type dcrwallet struct { + *wsrpc.Client +} + +func newWalletRPC(rpcURL, rpcUser, rpcPass string) (*dcrwallet, error) { + tlsOpt := wsrpc.WithTLSConfig(&tls.Config{ + InsecureSkipVerify: true, + }) + authOpt := wsrpc.WithBasicAuth(rpcUser, rpcPass) + rpc, err := wsrpc.Dial(context.TODO(), rpcURL, tlsOpt, authOpt) + if err != nil { + return nil, err + } + return &dcrwallet{rpc}, nil +} + +func (w *dcrwallet) createFeeTx(feeAddress string, fee int64) (string, error) { + amounts := make(map[string]float64) + amounts[feeAddress] = dcrutil.Amount(fee).ToCoin() + + var msgtxstr string + err := w.Call(context.TODO(), "createrawtransaction", &msgtxstr, nil, amounts) + if err != nil { + return "", err + } + + zero := int32(0) + opt := wallettypes.FundRawTransactionOptions{ + ConfTarget: &zero, + } + var fundTx wallettypes.FundRawTransactionResult + err = w.Call(context.TODO(), "fundrawtransaction", &fundTx, msgtxstr, "default", &opt) + if err != nil { + return "", err + } + + tx := wire.NewMsgTx() + err = tx.Deserialize(hex.NewDecoder(strings.NewReader(fundTx.Hex))) + if err != nil { + return "", err + } + + transactions := make([]dcrdtypes.TransactionInput, 0) + + for _, v := range tx.TxIn { + transactions = append(transactions, dcrdtypes.TransactionInput{ + Txid: v.PreviousOutPoint.Hash.String(), + Vout: v.PreviousOutPoint.Index, + }) + } + + var locked bool + unlock := false + err = w.Call(context.TODO(), "lockunspent", &locked, unlock, transactions) + if err != nil { + return "", err + } + + if !locked { + return "", errors.New("unspent output not locked") + } + + var signedTx wallettypes.SignRawTransactionResult + err = w.Call(context.TODO(), "signrawtransaction", &signedTx, fundTx.Hex) + if err != nil { + return "", err + } + if !signedTx.Complete { + return "", fmt.Errorf("not all signed") + } + return signedTx.Hex, nil +} + +func (w *dcrwallet) SignMessage(ctx context.Context, msg string, commitmentAddr stdaddr.Address) ([]byte, error) { + var signature string + err := w.Call(context.TODO(), "signmessage", &signature, commitmentAddr.String(), msg) + if err != nil { + return nil, err + } + + return base64.StdEncoding.DecodeString(signature) +} + +func (w *dcrwallet) dumpPrivKey(addr stdaddr.Address) (string, error) { + var privKeyStr string + err := w.Call(context.TODO(), "dumpprivkey", &privKeyStr, addr.String()) + if err != nil { + return "", err + } + return privKeyStr, nil +} + +func (w *dcrwallet) getTickets() (*wallettypes.GetTicketsResult, error) { + var tickets wallettypes.GetTicketsResult + includeImmature := true + err := w.Call(context.TODO(), "gettickets", &tickets, includeImmature) + if err != nil { + return nil, err + } + return &tickets, nil +} + +// getTicketDetails returns the ticket hex, privkey for voting, and the +// commitment address. +func (w *dcrwallet) getTicketDetails(ticketHash string) (string, string, stdaddr.Address, error) { + var getTransactionResult wallettypes.GetTransactionResult + err := w.Call(context.TODO(), "gettransaction", &getTransactionResult, ticketHash, false) + if err != nil { + fmt.Printf("gettransaction: %v\n", err) + return "", "", nil, err + } + + msgTx := wire.NewMsgTx() + if err = msgTx.Deserialize(hex.NewDecoder(strings.NewReader(getTransactionResult.Hex))); err != nil { + return "", "", nil, err + } + if len(msgTx.TxOut) < 2 { + return "", "", nil, errors.New("msgTx.TxOut < 2") + } + + const scriptVersion = 0 + scriptType, submissionAddr := stdscript.ExtractAddrs(scriptVersion, + msgTx.TxOut[0].PkScript, chaincfg.TestNet3Params()) + if scriptType == stdscript.STNonStandard { + return "", "", nil, fmt.Errorf("invalid script version %d", scriptVersion) + } + if len(submissionAddr) != 1 { + return "", "", nil, errors.New("submissionAddr != 1") + } + + addr, err := stake.AddrFromSStxPkScrCommitment(msgTx.TxOut[1].PkScript, + chaincfg.TestNet3Params()) + if err != nil { + return "", "", nil, err + } + + privKeyStr, err := w.dumpPrivKey(submissionAddr[0]) + if err != nil { + return "", "", nil, err + } + + return getTransactionResult.Hex, privKeyStr, addr, nil +} diff --git a/cmd/v3tool/main.go b/cmd/v3tool/main.go new file mode 100644 index 0000000..eca7924 --- /dev/null +++ b/cmd/v3tool/main.go @@ -0,0 +1,208 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "net/http" + "os" + "time" + + "github.com/decred/slog" + "github.com/decred/vspd/client" + "github.com/decred/vspd/types" +) + +const ( + vspdURL = "http://localhost:8800" + // dcrwallet RPC. + rpcURL = "wss://localhost:19110/ws" + rpcUser = "user" + rpcPass = "pass" +) + +func getVspPubKey(url string) ([]byte, error) { + resp, err := http.Get(url + "/api/v3/vspinfo") + if err != nil { + return nil, err + } + + b, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, err + } + + var j types.VspInfoResponse + err = json.Unmarshal(b, &j) + if err != nil { + return nil, err + } + + err = client.ValidateServerSignature(resp, b, j.PubKey) + if err != nil { + return nil, err + } + + return j.PubKey, nil +} + +func run() int { + log := slog.NewBackend(os.Stdout).Logger("") + log.SetLevel(slog.LevelTrace) + + walletRPC, err := newWalletRPC(rpcURL, rpcUser, rpcPass) + if err != nil { + log.Errorf("%v", err) + return 1 + } + defer walletRPC.Close() + + log.Infof("vpsd url: %s", vspdURL) + + pubKey, err := getVspPubKey(vspdURL) + if err != nil { + log.Errorf("%v", err) + return 1 + } + + log.Infof("vspd pubkey: %x", pubKey) + + vClient := client.Client{ + URL: vspdURL, + PubKey: pubKey, + Sign: walletRPC.SignMessage, + Log: log, + } + + if err != nil { + log.Errorf("%v", err) + return 1 + } + + // Get list of tickets + tickets, err := walletRPC.getTickets() + if err != nil { + log.Errorf("%v", err) + return 1 + } + + if len(tickets.Hashes) == 0 { + log.Errorf("wallet owns no tickets") + return 1 + } + + log.Infof("wallet returned %d ticket(s):", len(tickets.Hashes)) + for _, tkt := range tickets.Hashes { + log.Infof(" %s", tkt) + } + + for i := 0; i < len(tickets.Hashes); i++ { + ticketHash := tickets.Hashes[i] + hex, privKeyStr, commitmentAddr, err := walletRPC.getTicketDetails(ticketHash) + if err != nil { + log.Errorf("%v", err) + return 1 + } + + log.Infof("") + log.Infof("Processing ticket %d of %d:", i+1, len(tickets.Hashes)) + log.Infof(" Hash: %s", ticketHash) + log.Infof(" privKeyStr: %s", privKeyStr) + log.Infof(" commitmentAddr: %s", commitmentAddr) + log.Infof("") + + feeAddrReq := types.FeeAddressRequest{ + TicketHex: hex, + // Hack for ParentHex, can't be bothered to get the real one. It doesn't + // make a difference when testing locally anyway. + ParentHex: hex, + TicketHash: ticketHash, + Timestamp: time.Now().Unix(), + } + + feeAddrResp, err := vClient.FeeAddress(context.TODO(), feeAddrReq, commitmentAddr) + if err != nil { + log.Errorf("getFeeAddress error: %v", err) + break + } + + log.Infof("feeAddress: %v", feeAddrResp.FeeAddress) + log.Infof("privKeyStr: %v", privKeyStr) + + feeTx, err := walletRPC.createFeeTx(feeAddrResp.FeeAddress, feeAddrResp.FeeAmount) + if err != nil { + log.Errorf("createFeeTx error: %v", err) + break + } + + voteChoices := map[string]string{"autorevocations": "no"} + tspend := map[string]string{ + "6c78690fa2fa31803df0376897725704e9dc19ecbdf80061e79b69de93ca1360": "no", + "abb86660dda1f1b66544bab24a823a22e9213ada48649f0d913623f49e17dacb": "yes", + } + treasury := map[string]string{ + "03f6e7041f1cf51ee10e0a01cd2b0385ce3cd9debaabb2296f7e9dee9329da946c": "no", + "0319a37405cb4d1691971847d7719cfce70857c0f6e97d7c9174a3998cf0ab86dd": "yes", + } + + payFeeReq := types.PayFeeRequest{ + FeeTx: feeTx, + VotingKey: privKeyStr, + TicketHash: ticketHash, + Timestamp: time.Now().Unix(), + VoteChoices: voteChoices, + TSpendPolicy: tspend, + TreasuryPolicy: treasury, + } + + _, err = vClient.PayFee(context.TODO(), payFeeReq, commitmentAddr) + if err != nil { + log.Errorf("payFee error: %v", err) + continue + } + + ticketStatusReq := types.TicketStatusRequest{ + TicketHash: ticketHash, + } + + _, err = vClient.TicketStatus(context.TODO(), ticketStatusReq, commitmentAddr) + if err != nil { + log.Errorf("getTicketStatus error: %v", err) + break + } + + voteChoices["autorevocations"] = "yes" + + // Sleep to ensure a new timestamp. vspd will reject old/reused timestamps. + time.Sleep(1001 * time.Millisecond) + + voteChoiceReq := types.SetVoteChoicesRequest{ + Timestamp: time.Now().Unix(), + TicketHash: ticketHash, + VoteChoices: voteChoices, + TSpendPolicy: tspend, + TreasuryPolicy: treasury, + } + + _, err = vClient.SetVoteChoices(context.TODO(), voteChoiceReq, commitmentAddr) + if err != nil { + log.Errorf("setVoteChoices error: %v", err) + break + } + + _, err = vClient.TicketStatus(context.TODO(), ticketStatusReq, commitmentAddr) + if err != nil { + log.Errorf("getTicketStatus error: %v", err) + break + } + + time.Sleep(1 * time.Second) + } + + return 0 +} + +func main() { + os.Exit(run()) +} diff --git a/go.mod b/go.mod index bf219e1..77e19df 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/decred/dcrd/txscript/v4 v4.0.0 github.com/decred/dcrd/wire v1.5.0 github.com/decred/slog v1.2.0 + github.com/decred/vspd/client v1.1.0 github.com/decred/vspd/types v1.1.0 github.com/dustin/go-humanize v1.0.1 github.com/gin-gonic/gin v1.8.2 diff --git a/go.sum b/go.sum index 393b557..c3f4209 100644 --- a/go.sum +++ b/go.sum @@ -69,6 +69,8 @@ github.com/decred/dcrd/wire v1.5.0/go.mod h1:fzAjVqw32LkbAZIt5mnrvBR751GTa3e0rRQ github.com/decred/go-socks v1.1.0/go.mod h1:sDhHqkZH0X4JjSa02oYOGhcGHYp12FsY1jQ/meV8md0= github.com/decred/slog v1.2.0 h1:soHAxV52B54Di3WtKLfPum9OFfWqwtf/ygf9njdfnPM= github.com/decred/slog v1.2.0/go.mod h1:kVXlGnt6DHy2fV5OjSeuvCJ0OmlmTF6LFpEPMu/fOY0= +github.com/decred/vspd/client v1.1.0 h1:vyMjbT9jGuH96yNsv2msx5E/DP7xk3ePIU7XWhu1Des= +github.com/decred/vspd/client v1.1.0/go.mod h1:+gn860mKciikYxQrPm+hSVMEG8CQ7EWf/vN9pZa3Baw= github.com/decred/vspd/types v1.1.0 h1:hTeqQwgRUN2FGIbuCIdyzBejKV+jxKrmEIcLKxpsB1g= github.com/decred/vspd/types v1.1.0/go.mod h1:THsO8aBSwWBq6ZsIG25cNqbkNb+EEASXzLhFvODVc0s= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=