diff --git a/README.md b/README.md index 1f9b93a..9e85833 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,54 @@ [![ISC License](https://img.shields.io/badge/license-ISC-blue.svg)](http://copyfree.org) [![Go Report Card](https://goreportcard.com/badge/github.com/jholdstock/dcrvsp)](https://goreportcard.com/report/github.com/jholdstock/dcrvsp) -## Design decisions +## Overview -- [gin-gonic](https://github.com/gin-gonic/gin) webserver for both front-end and API. +User purchases a ticket, doesnt need any special conditions, indistinguishable +from solo ticket. User can then choose to use a VSP on a per-ticket basis. Once +the ticket is mined, and ideally before it has matured, the user sends the +ticket details + fee to a VSP, and the VSP will take the fee and vote in return. + +## Advantages + +### For Administrators + +- bbolt db. +- No stakepoold. +- Client accountability. + +### For Users + +- No redeem script to back up. +- No registration required. No email. +- Multiple VSPs on a single ticket. +- Voting preferences per ticket. +- Server accountability. +- No address reuse. +- VSP fees are paid "out of band", rather than being included in the ticket + itself. This makes solo tickets and VSP tickets indistinguishable from + eachother, enabling VSP users to purchase tickets in the same anonymity set + as solo stakers. + +## Design Decisions + +- [gin-gonic](https://github.com/gin-gonic/gin) webserver. - Success responses use HTTP status 200 and a JSON encoded body. - - Error responses use either HTTP status 500 or 400, and a JSON encoded error in the body (eg. `{"error":"Description"}') + - Error responses use either HTTP status 500 or 400, and a JSON encoded error + in the body (eg. `{"error":"Description"}') - [bbolt](https://github.com/etcd-io/bbolt) k/v database. - Tickets are stored in a single bucket, using ticket hash as the key and a json encoded representation of the ticket as the value. -- [wsrpc](https://github.com/jrick/wsrpc) for dcrwallet comms. +- [wsrpc](https://github.com/jrick/wsrpc) for RPC communication between dcrvsp + and dcrwallet. -## MVP features +## Architecture + +- Single server running dcrvsp, dcrwallet and dcrd. dcrd requires txindex so + `getrawtransaction` can be used. +- Multiple remote "Voting servers", each running dcrwallet and dcrd. dcrwallet + on these servers should be constantly unlocked and have voting enabled. + +## MVP Features - When dcrvsp is started for the first time, it generates a ed25519 keypair and stores it in the database. This key is used to sign all API responses, and the @@ -29,11 +66,12 @@ - Pay fee (`POST /payFee`) - Ticket status (`GET /ticketstatus`) - Set voting preferences (`POST /setvotechoices`) -- A minimal, static, web front-end providing pool stats and basic connection instructions. +- A minimal, static, web front-end providing pool stats and basic connection + instructions. - Fees have an expiry period. If the fee is not paid within this period, the client must request a new fee. This enables the VSP to alter its fee rate. -## Future features +## Future Features - Write database backups to disk periodically. - Backup over http. @@ -41,9 +79,11 @@ - Accountability for both client and server changes to voting preferences. - Consistency checking across connected wallets. -## Notes +## Backup and Recovery -- dcrd must have transaction index enabled so `getrawtransaction` can be used. +- Regular backups of bbolt database. +- Restore requires manual repair of fee wallet. Import xpub into account "fees", + and rescan with a very large gap limit. ## Issue Tracker diff --git a/config.go b/config.go index 2cbaebe..67fbd65 100644 --- a/config.go +++ b/config.go @@ -17,35 +17,41 @@ import ( ) var ( - defaultListen = ":3000" - defaultLogLevel = "debug" - defaultVSPFee = 0.01 - defaultNetwork = "testnet" - defaultHomeDir = dcrutil.AppDataDir("dcrvsp", false) - defaultConfigFilename = "dcrvsp.conf" - defaultConfigFile = filepath.Join(defaultHomeDir, defaultConfigFilename) - defaultWalletHost = "127.0.0.1" - defaultWebServerDebug = false + defaultListen = ":3000" + defaultLogLevel = "debug" + defaultVSPFee = 0.01 + defaultNetwork = "testnet" + defaultHomeDir = dcrutil.AppDataDir("dcrvsp", false) + defaultConfigFilename = "dcrvsp.conf" + defaultConfigFile = filepath.Join(defaultHomeDir, defaultConfigFilename) + defaultFeeWalletHost = "127.0.0.1" + defaultVotingWalletHost = "127.0.0.1" + defaultWebServerDebug = false ) // config defines the configuration options for the VSP. type config struct { - Listen string `long:"listen" ini-name:"listen" description:"The ip:port to listen for API requests."` - LogLevel string `long:"loglevel" ini-name:"loglevel" description:"Logging level." choice:"trace" choice:"debug" choice:"info" choice:"warn" choice:"error" choice:"critical"` - Network string `long:"network" ini-name:"network" description:"Decred network to use." choice:"testnet" choice:"mainnet" choice:"simnet"` - FeeXPub string `long:"feexpub" ini-name:"feexpub" description:"Cold wallet xpub used for collecting fees."` - VSPFee float64 `long:"vspfee" ini-name:"vspfee" description:"Fee percentage charged for VSP use. eg. 0.01 (1%), 0.05 (5%)."` - HomeDir string `long:"homedir" ini-name:"homedir" no-ini:"true" description:"Path to application home directory. Used for storing VSP database and logs."` - ConfigFile string `long:"configfile" ini-name:"configfile" no-ini:"true" description:"Path to configuration file."` - WalletHost string `long:"wallethost" ini-name:"wallethost" description:"The ip:port to establish a JSON-RPC connection with dcrwallet."` - WalletUser string `long:"walletuser" ini-name:"walletuser" description:"Username for dcrwallet RPC connections."` - WalletPass string `long:"walletpass" ini-name:"walletpass" description:"Password for dcrwallet RPC connections."` - WalletCert string `long:"walletcert" ini-name:"walletcert" description:"The dcrwallet RPC certificate file."` - WebServerDebug bool `long:"webserverdebug" ini-name:"webserverdebug" description:"Enable web server debug mode (verbose logging to terminal and live-reloading templates)."` + Listen string `long:"listen" ini-name:"listen" description:"The ip:port to listen for API requests."` + LogLevel string `long:"loglevel" ini-name:"loglevel" description:"Logging level." choice:"trace" choice:"debug" choice:"info" choice:"warn" choice:"error" choice:"critical"` + Network string `long:"network" ini-name:"network" description:"Decred network to use." choice:"testnet" choice:"mainnet" choice:"simnet"` + FeeXPub string `long:"feexpub" ini-name:"feexpub" description:"Cold wallet xpub used for collecting fees."` + VSPFee float64 `long:"vspfee" ini-name:"vspfee" description:"Fee percentage charged for VSP use. eg. 0.01 (1%), 0.05 (5%)."` + HomeDir string `long:"homedir" ini-name:"homedir" no-ini:"true" description:"Path to application home directory. Used for storing VSP database and logs."` + ConfigFile string `long:"configfile" ini-name:"configfile" no-ini:"true" description:"Path to configuration file."` + FeeWalletHost string `long:"feewallethost" ini-name:"feewallethost" description:"The ip:port to establish a JSON-RPC connection with fee dcrwallet."` + FeeWalletUser string `long:"feewalletuser" ini-name:"feewalletuser" description:"Username for fee dcrwallet RPC connections."` + FeeWalletPass string `long:"feewalletpass" ini-name:"feewalletpass" description:"Password for fee dcrwallet RPC connections."` + FeeWalletCert string `long:"feewalletcert" ini-name:"feewalletcert" description:"The fee dcrwallet RPC certificate file."` + VotingWalletHost string `long:"votingwallethost" ini-name:"votingwallethost" description:"The ip:port to establish a JSON-RPC connection with voting dcrwallet."` + VotingWalletUser string `long:"votingwalletuser" ini-name:"votingwalletuser" description:"Username for voting dcrwallet RPC connections."` + VotingWalletPass string `long:"votingwalletpass" ini-name:"votingwalletpass" description:"Password for voting dcrwallet RPC connections."` + VotingWalletCert string `long:"votingwalletcert" ini-name:"votingwalletcert" description:"The voting dcrwallet RPC certificate file."` + WebServerDebug bool `long:"webserverdebug" ini-name:"webserverdebug" description:"Enable web server debug mode (verbose logging to terminal and live-reloading templates)."` - dbPath string - netParams *netParams - dcrwCert []byte + dbPath string + netParams *netParams + feeWalletCert []byte + votingWalletCert []byte } // fileExists reports whether the named file or directory exists. @@ -136,14 +142,15 @@ func loadConfig() (*config, error) { // Default config. cfg := config{ - Listen: defaultListen, - LogLevel: defaultLogLevel, - Network: defaultNetwork, - VSPFee: defaultVSPFee, - HomeDir: defaultHomeDir, - ConfigFile: defaultConfigFile, - WalletHost: defaultWalletHost, - WebServerDebug: defaultWebServerDebug, + Listen: defaultListen, + LogLevel: defaultLogLevel, + Network: defaultNetwork, + VSPFee: defaultVSPFee, + HomeDir: defaultHomeDir, + ConfigFile: defaultConfigFile, + FeeWalletHost: defaultFeeWalletHost, + VotingWalletHost: defaultVotingWalletHost, + WebServerDebug: defaultWebServerDebug, } // Pre-parse the command line options to see if an alternative config @@ -233,30 +240,53 @@ func loadConfig() (*config, error) { cfg.netParams = &simNetParams } - // Ensure the dcrwallet RPC username is set. - if cfg.WalletUser == "" { - return nil, errors.New("the walletuser option is not set") + // Ensure the fee dcrwallet RPC username is set. + if cfg.FeeWalletUser == "" { + return nil, errors.New("the feewalletuser option is not set") } - // Ensure the dcrwallet RPC password is set. - if cfg.WalletPass == "" { - return nil, errors.New("the walletpass option is not set") + // Ensure the fee dcrwallet RPC password is set. + if cfg.FeeWalletPass == "" { + return nil, errors.New("the feewalletpass option is not set") } - // Ensure the dcrwallet RPC cert path is set. - if cfg.WalletCert == "" { - return nil, errors.New("the walletcert option is not set") + // Ensure the fee dcrwallet RPC cert path is set. + if cfg.FeeWalletCert == "" { + return nil, errors.New("the feewalletcert option is not set") + } + + // Load fee dcrwallet RPC certificate. + cfg.FeeWalletCert = cleanAndExpandPath(cfg.FeeWalletCert) + cfg.feeWalletCert, err = ioutil.ReadFile(cfg.FeeWalletCert) + if err != nil { + return nil, fmt.Errorf("failed to read fee dcrwallet cert file: %v", err) + } + + // Ensure the voting dcrwallet RPC username is set. + if cfg.VotingWalletUser == "" { + return nil, errors.New("the votingwalletuser option is not set") + } + + // Ensure the voting dcrwallet RPC password is set. + if cfg.VotingWalletPass == "" { + return nil, errors.New("the votingwalletpass option is not set") + } + + // Ensure the voting dcrwallet RPC cert path is set. + if cfg.VotingWalletCert == "" { + return nil, errors.New("the votingwalletcert option is not set") + } + + // Load voting dcrwallet RPC certificate. + cfg.VotingWalletCert = cleanAndExpandPath(cfg.VotingWalletCert) + cfg.votingWalletCert, err = ioutil.ReadFile(cfg.VotingWalletCert) + if err != nil { + return nil, fmt.Errorf("failed to read voting dcrwallet cert file: %v", err) } // Add default port for the active network if there is no port specified. - cfg.WalletHost = normalizeAddress(cfg.WalletHost, cfg.netParams.WalletRPCServerPort) - - // Load dcrwallet RPC certificate. - cfg.WalletCert = cleanAndExpandPath(cfg.WalletCert) - cfg.dcrwCert, err = ioutil.ReadFile(cfg.WalletCert) - if err != nil { - return nil, fmt.Errorf("failed to read dcrwallet cert file: %v", err) - } + cfg.FeeWalletHost = normalizeAddress(cfg.FeeWalletHost, cfg.netParams.WalletRPCServerPort) + cfg.VotingWalletHost = normalizeAddress(cfg.VotingWalletHost, cfg.netParams.WalletRPCServerPort) // Create the data directory. dataDir := filepath.Join(cfg.HomeDir, "data", cfg.netParams.Name) diff --git a/main.go b/main.go index 636f9bc..7329692 100644 --- a/main.go +++ b/main.go @@ -11,7 +11,6 @@ import ( "github.com/jholdstock/dcrvsp/database" "github.com/jholdstock/dcrvsp/rpc" "github.com/jholdstock/dcrvsp/webapi" - "github.com/jrick/wsrpc/v2" ) const ( @@ -59,11 +58,36 @@ func run(ctx context.Context) error { return err } - // Create dcrwallet RPC client. - walletRPC := rpc.Setup(ctx, &shutdownWg, cfg.WalletUser, cfg.WalletPass, cfg.WalletHost, cfg.dcrwCert) - walletClient, err := walletRPC() + // Create RPC client for local dcrwallet instance (used for generating fee + // addresses and broadcasting fee transactions). + feeWalletConnect := rpc.Setup(ctx, &shutdownWg, cfg.FeeWalletUser, cfg.FeeWalletPass, cfg.FeeWalletHost, cfg.feeWalletCert) + feeWalletConn, err := feeWalletConnect() if err != nil { - log.Errorf("dcrwallet RPC error: %v", err) + log.Errorf("Fee wallet connection error: %v", err) + requestShutdown() + shutdownWg.Wait() + return err + } + feeWalletClient, err := rpc.FeeWalletClient(ctx, feeWalletConn) + if err != nil { + log.Errorf("Fee wallet client error: %v", err) + requestShutdown() + shutdownWg.Wait() + return err + } + + // Create RPC client for remote dcrwallet instance (used for voting). + votingWalletConnect := rpc.Setup(ctx, &shutdownWg, cfg.VotingWalletUser, cfg.VotingWalletPass, cfg.VotingWalletHost, cfg.votingWalletCert) + votingWalletConn, err := votingWalletConnect() + if err != nil { + log.Errorf("Voting wallet connection error: %v", err) + requestShutdown() + shutdownWg.Wait() + return err + } + _, err = rpc.VotingWalletClient(ctx, votingWalletConn) + if err != nil { + log.Errorf("Voting wallet client error: %v", err) requestShutdown() shutdownWg.Wait() return err @@ -78,7 +102,7 @@ func run(ctx context.Context) error { } // Ensure the wallet account for collecting fees exists and matches config. - err = setupFeeAccount(ctx, walletClient, cfg.FeeXPub) + err = setupFeeAccount(ctx, feeWalletClient, cfg.FeeXPub) if err != nil { log.Errorf("Fee account error: %v", err) requestShutdown() @@ -95,7 +119,7 @@ func run(ctx context.Context) error { FeeAccountName: feeAccountName, FeeAddressExpiration: defaultFeeAddressExpiration, } - err = webapi.Start(ctx, shutdownRequestChannel, &shutdownWg, cfg.Listen, db, walletRPC, cfg.WebServerDebug, apiCfg) + err = webapi.Start(ctx, shutdownRequestChannel, &shutdownWg, cfg.Listen, db, feeWalletConnect, votingWalletConnect, cfg.WebServerDebug, apiCfg) if err != nil { log.Errorf("Failed to initialise webapi: %v", err) requestShutdown() @@ -109,20 +133,18 @@ func run(ctx context.Context) error { return ctx.Err() } -func setupFeeAccount(ctx context.Context, walletClient *wsrpc.Client, feeXpub string) error { +func setupFeeAccount(ctx context.Context, walletClient *rpc.FeeWalletRPC, feeXpub string) error { // Check if account for fee collection already exists. - var accounts map[string]float64 - err := walletClient.Call(ctx, "listaccounts", &accounts) + accounts, err := walletClient.ListAccounts(ctx) if err != nil { - return fmt.Errorf("dcrwallet RPC error: %v", err) + return fmt.Errorf("ListAccounts error: %v", err) } if _, ok := accounts[feeAccountName]; ok { // Account already exists. Check xpub matches xpub from config. - var existingXPub string - err = walletClient.Call(ctx, "getmasterpubkey", &existingXPub, feeAccountName) + existingXPub, err := walletClient.GetMasterPubKey(ctx, feeAccountName) if err != nil { - return fmt.Errorf("dcrwallet RPC error: %v", err) + return fmt.Errorf("GetMasterPubKey error: %v", err) } if existingXPub != feeXpub { @@ -133,8 +155,8 @@ func setupFeeAccount(ctx context.Context, walletClient *wsrpc.Client, feeXpub st } else { // Account does not exist. Create it using xpub from config. - if err = walletClient.Call(ctx, "importxpub", nil, feeAccountName, feeXpub); err != nil { - log.Errorf("Failed to import xpub: %v", err) + if err = walletClient.ImportXPub(ctx, feeAccountName, feeXpub); err != nil { + log.Errorf("ImportXPub error: %v", err) return err } log.Debugf("Created new wallet account %q to collect fees", feeAccountName) diff --git a/rpc/client.go b/rpc/client.go index 3ca8969..eeb9b95 100644 --- a/rpc/client.go +++ b/rpc/client.go @@ -4,25 +4,29 @@ import ( "context" "crypto/tls" "crypto/x509" - "fmt" "sync" - wallettypes "decred.org/dcrwallet/rpc/jsonrpc/types" - dcrdtypes "github.com/decred/dcrd/rpc/jsonrpc/types/v2" "github.com/jrick/wsrpc/v2" ) -type Client func() (*wsrpc.Client, error) +// Caller provides a client interface to perform JSON-RPC remote procedure calls. +type Caller interface { + // Call performs the remote procedure call defined by method and + // waits for a response or a broken client connection. + // Args provides positional parameters for the call. + // Res must be a pointer to a struct, slice, or map type to unmarshal + // a result (if any), or nil if no result is needed. + Call(ctx context.Context, method string, res interface{}, args ...interface{}) error +} -const ( - requiredWalletVersion = "8.1.0" -) +// Connect dials and returns a connected RPC client. +type Connect func() (Caller, error) // Setup accepts RPC connection details, creates an RPC client, and returns a // function which can be called to access the client. The returned function will // try to handle any client disconnects by attempting to reconnect, but will // return an error if a new connection cannot be established. -func Setup(ctx context.Context, shutdownWg *sync.WaitGroup, user, pass, addr string, cert []byte) Client { +func Setup(ctx context.Context, shutdownWg *sync.WaitGroup, user, pass, addr string, cert []byte) Connect { // Create TLS options. pool := x509.NewCertPool() @@ -33,6 +37,8 @@ func Setup(ctx context.Context, shutdownWg *sync.WaitGroup, user, pass, addr str // Create authentication options. authOpt := wsrpc.WithBasicAuth(user, pass) + fullAddr := "wss://" + addr + "/ws" + var mu sync.Mutex var c *wsrpc.Client @@ -59,7 +65,7 @@ func Setup(ctx context.Context, shutdownWg *sync.WaitGroup, user, pass, addr str shutdownWg.Done() }() - return func() (*wsrpc.Client, error) { + return func() (Caller, error) { defer mu.Unlock() mu.Lock() @@ -73,45 +79,11 @@ func Setup(ctx context.Context, shutdownWg *sync.WaitGroup, user, pass, addr str } } - fullAddr := "wss://" + addr + "/ws" - c, err := wsrpc.Dial(ctx, fullAddr, tlsOpt, authOpt) + var err error + c, err = wsrpc.Dial(ctx, fullAddr, tlsOpt, authOpt) if err != nil { return nil, err } - log.Infof("Dialed RPC websocket %v", addr) - - // Verify dcrwallet at is at the required api version - var verMap map[string]dcrdtypes.VersionResult - err = c.Call(ctx, "version", &verMap) - if err != nil { - c.Close() - return nil, fmt.Errorf("wallet %v version failed: %v", - addr, err) - } - walletVersion, exists := verMap["dcrwalletjsonrpcapi"] - if !exists { - c.Close() - return nil, fmt.Errorf("wallet %v version response "+ - "missing 'dcrwalletjsonrpcapi'", addr) - } - if walletVersion.VersionString != requiredWalletVersion { - c.Close() - return nil, fmt.Errorf("wallet %v is not at the "+ - "proper version: %s != %s", addr, - walletVersion.VersionString, requiredWalletVersion) - } - - // Verify dcrwallet is voting - var walletInfo wallettypes.WalletInfoResult - err = c.Call(ctx, "walletinfo", &walletInfo) - if err != nil { - c.Close() - return nil, err - } - if !walletInfo.Voting || !walletInfo.Unlocked { - c.Close() - return nil, fmt.Errorf("wallet %s has voting disabled", addr) - } return c, nil } diff --git a/rpc/feewallet.go b/rpc/feewallet.go new file mode 100644 index 0000000..8bc8458 --- /dev/null +++ b/rpc/feewallet.go @@ -0,0 +1,113 @@ +package rpc + +import ( + "context" + "fmt" + + wallettypes "decred.org/dcrwallet/rpc/jsonrpc/types" + dcrdtypes "github.com/decred/dcrd/rpc/jsonrpc/types/v2" +) + +const ( + requiredFeeWalletVersion = "8.1.0" +) + +// FeeWalletRPC provides methods for calling dcrwallet JSON-RPCs without exposing the details +// of JSON encoding. +type FeeWalletRPC struct { + Caller +} + +// FeeWalletClient creates a new WalletRPC client instance from a caller. +func FeeWalletClient(ctx context.Context, c Caller) (*FeeWalletRPC, error) { + + // Verify dcrwallet is at the required api version. + var verMap map[string]dcrdtypes.VersionResult + err := c.Call(ctx, "version", &verMap) + if err != nil { + return nil, fmt.Errorf("version check failed: %v", err) + } + walletVersion, exists := verMap["dcrwalletjsonrpcapi"] + if !exists { + return nil, fmt.Errorf("version response missing 'dcrwalletjsonrpcapi'") + } + if walletVersion.VersionString != requiredFeeWalletVersion { + return nil, fmt.Errorf("wrong dcrwallet RPC version: expected %s, got %s", + walletVersion.VersionString, requiredFeeWalletVersion) + } + + // Verify dcrwallet is connected to dcrd (not SPV). + var walletInfo wallettypes.WalletInfoResult + err = c.Call(ctx, "walletinfo", &walletInfo) + if err != nil { + return nil, fmt.Errorf("walletinfo check failed: %v", err) + } + if !walletInfo.DaemonConnected { + return nil, fmt.Errorf("wallet is not connected to dcrd") + } + + // TODO: Ensure correct network. + + return &FeeWalletRPC{c}, nil +} + +func (c *FeeWalletRPC) ImportXPub(ctx context.Context, account, xpub string) error { + return c.Call(ctx, "importxpub", nil, account, xpub) +} + +func (c *FeeWalletRPC) GetMasterPubKey(ctx context.Context, account string) (string, error) { + var pubKey string + err := c.Call(ctx, "getmasterpubkey", &pubKey, account) + if err != nil { + return "", err + } + return pubKey, nil +} + +func (c *FeeWalletRPC) ListAccounts(ctx context.Context) (map[string]float64, error) { + var accounts map[string]float64 + err := c.Call(ctx, "listaccounts", &accounts) + if err != nil { + return nil, err + } + return accounts, nil +} + +func (c *FeeWalletRPC) GetNewAddress(ctx context.Context, account string) (string, error) { + var newAddress string + err := c.Call(ctx, "getnewaddress", &newAddress, account) + if err != nil { + return "", err + } + return newAddress, nil +} + +func (c *FeeWalletRPC) GetBlockHeader(ctx context.Context, blockHash string) (*dcrdtypes.GetBlockHeaderVerboseResult, error) { + verbose := true + var blockHeader dcrdtypes.GetBlockHeaderVerboseResult + err := c.Call(ctx, "getblockheader", &blockHeader, blockHash, verbose) + if err != nil { + return nil, err + } + return &blockHeader, nil +} + +func (c *FeeWalletRPC) GetRawTransaction(ctx context.Context, txHash string) (*dcrdtypes.TxRawResult, error) { + verbose := 1 + var resp dcrdtypes.TxRawResult + err := c.Call(ctx, "getrawtransaction", &resp, txHash, verbose) + if err != nil { + return nil, err + } + return &resp, nil +} + +func (c *FeeWalletRPC) SendRawTransaction(ctx context.Context, txHex string) (string, error) { + allowHighFees := false + var txHash string + err := c.Call(ctx, "sendrawtransaction", &txHash, txHex, allowHighFees) + if err != nil { + return "", err + } + return txHash, nil +} diff --git a/rpc/votingwallet.go b/rpc/votingwallet.go new file mode 100644 index 0000000..7776ffe --- /dev/null +++ b/rpc/votingwallet.go @@ -0,0 +1,73 @@ +package rpc + +import ( + "context" + "fmt" + + wallettypes "decred.org/dcrwallet/rpc/jsonrpc/types" + dcrdtypes "github.com/decred/dcrd/rpc/jsonrpc/types/v2" +) + +const ( + requiredVotingWalletVersion = "8.1.0" +) + +// VotingWalletRPC provides methods for calling dcrwallet JSON-RPCs without exposing the details +// of JSON encoding. +type VotingWalletRPC struct { + Caller +} + +// VotingWalletClient creates a new VotingWalletRPC client instance from a caller. +func VotingWalletClient(ctx context.Context, c Caller) (*VotingWalletRPC, error) { + + // Verify dcrwallet is at the required api version. + var verMap map[string]dcrdtypes.VersionResult + err := c.Call(ctx, "version", &verMap) + if err != nil { + return nil, fmt.Errorf("version check failed: %v", err) + } + walletVersion, exists := verMap["dcrwalletjsonrpcapi"] + if !exists { + return nil, fmt.Errorf("version response missing 'dcrwalletjsonrpcapi'") + } + if walletVersion.VersionString != requiredVotingWalletVersion { + return nil, fmt.Errorf("wrong dcrwallet RPC version: expected %s, got %s", + walletVersion.VersionString, requiredVotingWalletVersion) + } + + // Verify dcrwallet is voting, unlocked, and is connected to dcrd (not SPV). + var walletInfo wallettypes.WalletInfoResult + err = c.Call(ctx, "walletinfo", &walletInfo) + if err != nil { + return nil, fmt.Errorf("walletinfo check failed: %v", err) + } + if !walletInfo.Voting { + return nil, fmt.Errorf("wallet has voting disabled") + } + if !walletInfo.Unlocked { + return nil, fmt.Errorf("wallet is not unlocked") + } + if !walletInfo.DaemonConnected { + return nil, fmt.Errorf("wallet is not connected to dcrd") + } + + // TODO: Ensure correct network. + + return &VotingWalletRPC{c}, nil +} + +func (c *VotingWalletRPC) AddTransaction(ctx context.Context, blockHash, txHex string) error { + return c.Call(ctx, "addtransaction", nil, blockHash, txHex) +} + +func (c *VotingWalletRPC) ImportPrivKey(ctx context.Context, votingWIF string) error { + label := "imported" + rescan := false + scanFrom := 0 + return c.Call(ctx, "importprivkey", nil, votingWIF, label, rescan, scanFrom) +} + +func (c *VotingWalletRPC) SetVoteChoice(ctx context.Context, agenda, choice, ticketHash string) error { + return c.Call(ctx, "setvotechoice", nil, agenda, choice, ticketHash) +} diff --git a/webapi/getfeeaddress.go b/webapi/getfeeaddress.go index 0201a04..98a4261 100644 --- a/webapi/getfeeaddress.go +++ b/webapi/getfeeaddress.go @@ -11,10 +11,10 @@ import ( "github.com/decred/dcrd/blockchain/stake/v3" "github.com/decred/dcrd/chaincfg/chainhash" "github.com/decred/dcrd/dcrutil/v3" - dcrdtypes "github.com/decred/dcrd/rpc/jsonrpc/types/v2" "github.com/decred/dcrd/wire" "github.com/gin-gonic/gin" "github.com/jholdstock/dcrvsp/database" + "github.com/jholdstock/dcrvsp/rpc" ) // feeAddress is the handler for "POST /feeaddress" @@ -82,17 +82,21 @@ func feeAddress(c *gin.Context) { return } - walletClient, err := walletRPC() + fWalletConn, err := feeWalletConnect() if err != nil { - log.Errorf("Failed to dial dcrwallet RPC: %v", err) + log.Errorf("Fee wallet connection error: %v", err) + sendErrorResponse("dcrwallet RPC error", http.StatusInternalServerError, c) + return + } + ctx := c.Request.Context() + fWalletClient, err := rpc.FeeWalletClient(ctx, fWalletConn) + if err != nil { + log.Errorf("Fee wallet client error: %v", err) sendErrorResponse("dcrwallet RPC error", http.StatusInternalServerError, c) return } - ctx := c.Request.Context() - - var resp dcrdtypes.TxRawResult - err = walletClient.Call(ctx, "getrawtransaction", &resp, txHash.String(), 1) + resp, err := fWalletClient.GetRawTransaction(ctx, txHash.String()) if err != nil { log.Warnf("Could not retrieve tx %s for %s: %v", txHash, c.ClientIP(), err) sendErrorResponse("unknown transaction", http.StatusBadRequest, c) @@ -153,16 +157,15 @@ func feeAddress(c *gin.Context) { // get blockheight and sdiff which is required by // txrules.StakePoolTicketFee, and store them in the database // for processing by payfee - var blockHeader dcrdtypes.GetBlockHeaderVerboseResult - err = walletClient.Call(ctx, "getblockheader", &blockHeader, resp.BlockHash, true) + blockHeader, err := fWalletClient.GetBlockHeader(ctx, resp.BlockHash) if err != nil { log.Errorf("GetBlockHeader error: %v", err) sendErrorResponse("dcrwallet RPC error", http.StatusInternalServerError, c) return } - var newAddress string - err = walletClient.Call(ctx, "getnewaddress", &newAddress, "fees") + // TODO: Generate this within dcrvsp without an RPC call? + newAddress, err := fWalletClient.GetNewAddress(ctx, cfg.FeeAccountName) if err != nil { log.Errorf("GetNewAddress error: %v", err) sendErrorResponse("unable to generate fee address", http.StatusInternalServerError, c) diff --git a/webapi/payfee.go b/webapi/payfee.go index 5ac8479..a97cc30 100644 --- a/webapi/payfee.go +++ b/webapi/payfee.go @@ -10,10 +10,10 @@ import ( "github.com/decred/dcrd/chaincfg/chainhash" "github.com/decred/dcrd/dcrec" "github.com/decred/dcrd/dcrutil/v3" - dcrdtypes "github.com/decred/dcrd/rpc/jsonrpc/types/v2" "github.com/decred/dcrd/txscript/v3" "github.com/decred/dcrd/wire" "github.com/gin-gonic/gin" + "github.com/jholdstock/dcrvsp/rpc" ) // payFee is the handler for "POST /payfee" @@ -131,31 +131,49 @@ findAddress: return } - walletClient, err := walletRPC() + fWalletConn, err := feeWalletConnect() if err != nil { - log.Errorf("Failed to dial dcrwallet RPC: %v", err) + log.Errorf("Fee wallet connection error: %v", err) sendErrorResponse("dcrwallet RPC error", http.StatusInternalServerError, c) return } - ctx := c.Request.Context() - var rawTicket dcrdtypes.TxRawResult - err = walletClient.Call(ctx, "getrawtransaction", &rawTicket, ticketHash.String(), 1) + fWalletClient, err := rpc.FeeWalletClient(ctx, fWalletConn) if err != nil { - log.Errorf("GetRawTransaction failed: %v", err) + log.Errorf("Fee wallet client error: %v", err) sendErrorResponse("dcrwallet RPC error", http.StatusInternalServerError, c) return } - err = walletClient.Call(ctx, "addtransaction", nil, rawTicket.BlockHash, rawTicket.Hex) + rawTicket, err := fWalletClient.GetRawTransaction(ctx, ticketHash.String()) + if err != nil { + log.Warnf("Could not retrieve tx %s for %s: %v", ticketHash.String(), c.ClientIP(), err) + sendErrorResponse("unknown transaction", http.StatusBadRequest, c) + return + } + + vWalletConn, err := votingWalletConnect() + if err != nil { + log.Errorf("Voting wallet connection error: %v", err) + sendErrorResponse("dcrwallet RPC error", http.StatusInternalServerError, c) + return + } + vWalletClient, err := rpc.VotingWalletClient(ctx, vWalletConn) + if err != nil { + log.Errorf("Voting wallet client error: %v", err) + sendErrorResponse("dcrwallet RPC error", http.StatusInternalServerError, c) + return + } + + err = vWalletClient.AddTransaction(ctx, rawTicket.BlockHash, rawTicket.Hex) if err != nil { log.Errorf("AddTransaction failed: %v", err) sendErrorResponse("dcrwallet RPC error", http.StatusInternalServerError, c) return } - err = walletClient.Call(ctx, "importprivkey", nil, votingWIF.String(), "imported", false, 0) + err = vWalletClient.ImportPrivKey(ctx, votingWIF.String()) if err != nil { log.Errorf("ImportPrivKey failed: %v", err) sendErrorResponse("dcrwallet RPC error", http.StatusInternalServerError, c) @@ -164,9 +182,9 @@ findAddress: // Update vote choices on voting wallets. for agenda, choice := range voteChoices { - err = walletClient.Call(ctx, "setvotechoice", nil, agenda, choice, ticket.Hash) + err = vWalletClient.SetVoteChoice(ctx, agenda, choice, ticket.Hash) if err != nil { - log.Errorf("setvotechoice failed: %v", err) + log.Errorf("SetVoteChoice failed: %v", err) sendErrorResponse("dcrwallet RPC error", http.StatusInternalServerError, c) return } @@ -181,8 +199,7 @@ findAddress: return } - var sendTxHash string - err = walletClient.Call(ctx, "sendrawtransaction", &sendTxHash, hex.EncodeToString(feeTxBuf.Bytes()), false) + sendTxHash, err := fWalletClient.SendRawTransaction(ctx, hex.EncodeToString(feeTxBuf.Bytes())) if err != nil { log.Errorf("SendRawTransaction failed: %v", err) sendErrorResponse("dcrwallet RPC error", http.StatusInternalServerError, c) diff --git a/webapi/setvotechoices.go b/webapi/setvotechoices.go index ba94a89..1acb3fb 100644 --- a/webapi/setvotechoices.go +++ b/webapi/setvotechoices.go @@ -9,6 +9,7 @@ import ( "github.com/decred/dcrd/chaincfg/chainhash" "github.com/decred/dcrd/dcrutil/v3" "github.com/gin-gonic/gin" + "github.com/jholdstock/dcrvsp/rpc" ) // setVoteChoices is the handler for "POST /setvotechoices" @@ -61,25 +62,33 @@ func setVoteChoices(c *gin.Context) { return } - walletClient, err := walletRPC() + vWalletConn, err := votingWalletConnect() if err != nil { - log.Errorf("Failed to dial dcrwallet RPC: %v", err) + log.Errorf("Voting wallet connection error: %v", err) sendErrorResponse("dcrwallet RPC error", http.StatusInternalServerError, c) return } ctx := c.Request.Context() + vWalletClient, err := rpc.VotingWalletClient(ctx, vWalletConn) + if err != nil { + log.Errorf("Voting wallet client error: %v", err) + sendErrorResponse("dcrwallet RPC error", http.StatusInternalServerError, c) + return + } // Update vote choices on voting wallets. for agenda, choice := range voteChoices { - err = walletClient.Call(ctx, "setvotechoice", nil, agenda, choice, ticket.Hash) + err = vWalletClient.SetVoteChoice(ctx, agenda, choice, ticket.Hash) if err != nil { - log.Errorf("setvotechoice failed: %v", err) + log.Errorf("SetVoteChoice failed: %v", err) sendErrorResponse("dcrwallet RPC error", http.StatusInternalServerError, c) return } } + // TODO: Update database before updating wallets. DB is source of truth and + // is less likely to error. err = db.UpdateVoteChoices(txHash.String(), voteChoices) if err != nil { log.Errorf("UpdateVoteChoices error: %v", err) diff --git a/webapi/webapi.go b/webapi/webapi.go index bc7313d..6f45ba0 100644 --- a/webapi/webapi.go +++ b/webapi/webapi.go @@ -28,10 +28,11 @@ type Config struct { var cfg Config var db *database.VspDatabase -var walletRPC rpc.Client +var feeWalletConnect rpc.Connect +var votingWalletConnect rpc.Connect func Start(ctx context.Context, requestShutdownChan chan struct{}, shutdownWg *sync.WaitGroup, - listen string, vdb *database.VspDatabase, wRPC rpc.Client, debugMode bool, config Config) error { + listen string, vdb *database.VspDatabase, fWalletConnect rpc.Connect, vWalletConnect rpc.Connect, debugMode bool, config Config) error { // Create TCP listener. var listenConfig net.ListenConfig @@ -79,7 +80,8 @@ func Start(ctx context.Context, requestShutdownChan chan struct{}, shutdownWg *s cfg = config db = vdb - walletRPC = wRPC + feeWalletConnect = fWalletConnect + votingWalletConnect = vWalletConnect return nil }