Split voting wallet and fee wallet clients (#45)

This commit is contained in:
Jamie Holdstock 2020-05-22 07:54:09 +01:00 committed by GitHub
parent 033ac95c33
commit 869b68fad5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 431 additions and 150 deletions

View File

@ -4,17 +4,54 @@
[![ISC License](https://img.shields.io/badge/license-ISC-blue.svg)](http://copyfree.org) [![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) [![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. - 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. - [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 - Tickets are stored in a single bucket, using ticket hash as the key and a
json encoded representation of the ticket as the value. 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 - 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 stores it in the database. This key is used to sign all API responses, and the
@ -29,11 +66,12 @@
- Pay fee (`POST /payFee`) - Pay fee (`POST /payFee`)
- Ticket status (`GET /ticketstatus`) - Ticket status (`GET /ticketstatus`)
- Set voting preferences (`POST /setvotechoices`) - 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 - 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. 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. - Write database backups to disk periodically.
- Backup over http. - Backup over http.
@ -41,9 +79,11 @@
- Accountability for both client and server changes to voting preferences. - Accountability for both client and server changes to voting preferences.
- Consistency checking across connected wallets. - 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 ## Issue Tracker

View File

@ -24,7 +24,8 @@ var (
defaultHomeDir = dcrutil.AppDataDir("dcrvsp", false) defaultHomeDir = dcrutil.AppDataDir("dcrvsp", false)
defaultConfigFilename = "dcrvsp.conf" defaultConfigFilename = "dcrvsp.conf"
defaultConfigFile = filepath.Join(defaultHomeDir, defaultConfigFilename) defaultConfigFile = filepath.Join(defaultHomeDir, defaultConfigFilename)
defaultWalletHost = "127.0.0.1" defaultFeeWalletHost = "127.0.0.1"
defaultVotingWalletHost = "127.0.0.1"
defaultWebServerDebug = false defaultWebServerDebug = false
) )
@ -37,15 +38,20 @@ type config struct {
VSPFee float64 `long:"vspfee" ini-name:"vspfee" description:"Fee percentage charged for VSP use. eg. 0.01 (1%), 0.05 (5%)."` 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."` 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."` 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."` FeeWalletHost string `long:"feewallethost" ini-name:"feewallethost" description:"The ip:port to establish a JSON-RPC connection with fee dcrwallet."`
WalletUser string `long:"walletuser" ini-name:"walletuser" description:"Username for dcrwallet RPC connections."` FeeWalletUser string `long:"feewalletuser" ini-name:"feewalletuser" description:"Username for fee dcrwallet RPC connections."`
WalletPass string `long:"walletpass" ini-name:"walletpass" description:"Password for dcrwallet RPC connections."` FeeWalletPass string `long:"feewalletpass" ini-name:"feewalletpass" description:"Password for fee dcrwallet RPC connections."`
WalletCert string `long:"walletcert" ini-name:"walletcert" description:"The dcrwallet RPC certificate file."` 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)."` WebServerDebug bool `long:"webserverdebug" ini-name:"webserverdebug" description:"Enable web server debug mode (verbose logging to terminal and live-reloading templates)."`
dbPath string dbPath string
netParams *netParams netParams *netParams
dcrwCert []byte feeWalletCert []byte
votingWalletCert []byte
} }
// fileExists reports whether the named file or directory exists. // fileExists reports whether the named file or directory exists.
@ -142,7 +148,8 @@ func loadConfig() (*config, error) {
VSPFee: defaultVSPFee, VSPFee: defaultVSPFee,
HomeDir: defaultHomeDir, HomeDir: defaultHomeDir,
ConfigFile: defaultConfigFile, ConfigFile: defaultConfigFile,
WalletHost: defaultWalletHost, FeeWalletHost: defaultFeeWalletHost,
VotingWalletHost: defaultVotingWalletHost,
WebServerDebug: defaultWebServerDebug, WebServerDebug: defaultWebServerDebug,
} }
@ -233,30 +240,53 @@ func loadConfig() (*config, error) {
cfg.netParams = &simNetParams cfg.netParams = &simNetParams
} }
// Ensure the dcrwallet RPC username is set. // Ensure the fee dcrwallet RPC username is set.
if cfg.WalletUser == "" { if cfg.FeeWalletUser == "" {
return nil, errors.New("the walletuser option is not set") return nil, errors.New("the feewalletuser option is not set")
} }
// Ensure the dcrwallet RPC password is set. // Ensure the fee dcrwallet RPC password is set.
if cfg.WalletPass == "" { if cfg.FeeWalletPass == "" {
return nil, errors.New("the walletpass option is not set") return nil, errors.New("the feewalletpass option is not set")
} }
// Ensure the dcrwallet RPC cert path is set. // Ensure the fee dcrwallet RPC cert path is set.
if cfg.WalletCert == "" { if cfg.FeeWalletCert == "" {
return nil, errors.New("the walletcert option is not set") 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. // Add default port for the active network if there is no port specified.
cfg.WalletHost = normalizeAddress(cfg.WalletHost, cfg.netParams.WalletRPCServerPort) cfg.FeeWalletHost = normalizeAddress(cfg.FeeWalletHost, cfg.netParams.WalletRPCServerPort)
cfg.VotingWalletHost = normalizeAddress(cfg.VotingWalletHost, 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)
}
// Create the data directory. // Create the data directory.
dataDir := filepath.Join(cfg.HomeDir, "data", cfg.netParams.Name) dataDir := filepath.Join(cfg.HomeDir, "data", cfg.netParams.Name)

54
main.go
View File

@ -11,7 +11,6 @@ import (
"github.com/jholdstock/dcrvsp/database" "github.com/jholdstock/dcrvsp/database"
"github.com/jholdstock/dcrvsp/rpc" "github.com/jholdstock/dcrvsp/rpc"
"github.com/jholdstock/dcrvsp/webapi" "github.com/jholdstock/dcrvsp/webapi"
"github.com/jrick/wsrpc/v2"
) )
const ( const (
@ -59,11 +58,36 @@ func run(ctx context.Context) error {
return err return err
} }
// Create dcrwallet RPC client. // Create RPC client for local dcrwallet instance (used for generating fee
walletRPC := rpc.Setup(ctx, &shutdownWg, cfg.WalletUser, cfg.WalletPass, cfg.WalletHost, cfg.dcrwCert) // addresses and broadcasting fee transactions).
walletClient, err := walletRPC() feeWalletConnect := rpc.Setup(ctx, &shutdownWg, cfg.FeeWalletUser, cfg.FeeWalletPass, cfg.FeeWalletHost, cfg.feeWalletCert)
feeWalletConn, err := feeWalletConnect()
if err != nil { 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() requestShutdown()
shutdownWg.Wait() shutdownWg.Wait()
return err return err
@ -78,7 +102,7 @@ func run(ctx context.Context) error {
} }
// Ensure the wallet account for collecting fees exists and matches config. // 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 { if err != nil {
log.Errorf("Fee account error: %v", err) log.Errorf("Fee account error: %v", err)
requestShutdown() requestShutdown()
@ -95,7 +119,7 @@ func run(ctx context.Context) error {
FeeAccountName: feeAccountName, FeeAccountName: feeAccountName,
FeeAddressExpiration: defaultFeeAddressExpiration, 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 { if err != nil {
log.Errorf("Failed to initialise webapi: %v", err) log.Errorf("Failed to initialise webapi: %v", err)
requestShutdown() requestShutdown()
@ -109,20 +133,18 @@ func run(ctx context.Context) error {
return ctx.Err() 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. // Check if account for fee collection already exists.
var accounts map[string]float64 accounts, err := walletClient.ListAccounts(ctx)
err := walletClient.Call(ctx, "listaccounts", &accounts)
if err != nil { if err != nil {
return fmt.Errorf("dcrwallet RPC error: %v", err) return fmt.Errorf("ListAccounts error: %v", err)
} }
if _, ok := accounts[feeAccountName]; ok { if _, ok := accounts[feeAccountName]; ok {
// Account already exists. Check xpub matches xpub from config. // Account already exists. Check xpub matches xpub from config.
var existingXPub string existingXPub, err := walletClient.GetMasterPubKey(ctx, feeAccountName)
err = walletClient.Call(ctx, "getmasterpubkey", &existingXPub, feeAccountName)
if err != nil { if err != nil {
return fmt.Errorf("dcrwallet RPC error: %v", err) return fmt.Errorf("GetMasterPubKey error: %v", err)
} }
if existingXPub != feeXpub { if existingXPub != feeXpub {
@ -133,8 +155,8 @@ func setupFeeAccount(ctx context.Context, walletClient *wsrpc.Client, feeXpub st
} else { } else {
// Account does not exist. Create it using xpub from config. // Account does not exist. Create it using xpub from config.
if err = walletClient.Call(ctx, "importxpub", nil, feeAccountName, feeXpub); err != nil { if err = walletClient.ImportXPub(ctx, feeAccountName, feeXpub); err != nil {
log.Errorf("Failed to import xpub: %v", err) log.Errorf("ImportXPub error: %v", err)
return err return err
} }
log.Debugf("Created new wallet account %q to collect fees", feeAccountName) log.Debugf("Created new wallet account %q to collect fees", feeAccountName)

View File

@ -4,25 +4,29 @@ import (
"context" "context"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"fmt"
"sync" "sync"
wallettypes "decred.org/dcrwallet/rpc/jsonrpc/types"
dcrdtypes "github.com/decred/dcrd/rpc/jsonrpc/types/v2"
"github.com/jrick/wsrpc/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 ( // Connect dials and returns a connected RPC client.
requiredWalletVersion = "8.1.0" type Connect func() (Caller, error)
)
// Setup accepts RPC connection details, creates an RPC client, and returns a // 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 // 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 // try to handle any client disconnects by attempting to reconnect, but will
// return an error if a new connection cannot be established. // 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. // Create TLS options.
pool := x509.NewCertPool() pool := x509.NewCertPool()
@ -33,6 +37,8 @@ func Setup(ctx context.Context, shutdownWg *sync.WaitGroup, user, pass, addr str
// Create authentication options. // Create authentication options.
authOpt := wsrpc.WithBasicAuth(user, pass) authOpt := wsrpc.WithBasicAuth(user, pass)
fullAddr := "wss://" + addr + "/ws"
var mu sync.Mutex var mu sync.Mutex
var c *wsrpc.Client var c *wsrpc.Client
@ -59,7 +65,7 @@ func Setup(ctx context.Context, shutdownWg *sync.WaitGroup, user, pass, addr str
shutdownWg.Done() shutdownWg.Done()
}() }()
return func() (*wsrpc.Client, error) { return func() (Caller, error) {
defer mu.Unlock() defer mu.Unlock()
mu.Lock() mu.Lock()
@ -73,45 +79,11 @@ func Setup(ctx context.Context, shutdownWg *sync.WaitGroup, user, pass, addr str
} }
} }
fullAddr := "wss://" + addr + "/ws" var err error
c, err := wsrpc.Dial(ctx, fullAddr, tlsOpt, authOpt) c, err = wsrpc.Dial(ctx, fullAddr, tlsOpt, authOpt)
if err != nil { if err != nil {
return nil, err 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 return c, nil
} }

113
rpc/feewallet.go Normal file
View File

@ -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
}

73
rpc/votingwallet.go Normal file
View File

@ -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)
}

View File

@ -11,10 +11,10 @@ import (
"github.com/decred/dcrd/blockchain/stake/v3" "github.com/decred/dcrd/blockchain/stake/v3"
"github.com/decred/dcrd/chaincfg/chainhash" "github.com/decred/dcrd/chaincfg/chainhash"
"github.com/decred/dcrd/dcrutil/v3" "github.com/decred/dcrd/dcrutil/v3"
dcrdtypes "github.com/decred/dcrd/rpc/jsonrpc/types/v2"
"github.com/decred/dcrd/wire" "github.com/decred/dcrd/wire"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/jholdstock/dcrvsp/database" "github.com/jholdstock/dcrvsp/database"
"github.com/jholdstock/dcrvsp/rpc"
) )
// feeAddress is the handler for "POST /feeaddress" // feeAddress is the handler for "POST /feeaddress"
@ -82,17 +82,21 @@ func feeAddress(c *gin.Context) {
return return
} }
walletClient, err := walletRPC() fWalletConn, err := feeWalletConnect()
if err != nil { 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) sendErrorResponse("dcrwallet RPC error", http.StatusInternalServerError, c)
return return
} }
ctx := c.Request.Context() resp, err := fWalletClient.GetRawTransaction(ctx, txHash.String())
var resp dcrdtypes.TxRawResult
err = walletClient.Call(ctx, "getrawtransaction", &resp, txHash.String(), 1)
if err != nil { if err != nil {
log.Warnf("Could not retrieve tx %s for %s: %v", txHash, c.ClientIP(), err) log.Warnf("Could not retrieve tx %s for %s: %v", txHash, c.ClientIP(), err)
sendErrorResponse("unknown transaction", http.StatusBadRequest, c) sendErrorResponse("unknown transaction", http.StatusBadRequest, c)
@ -153,16 +157,15 @@ func feeAddress(c *gin.Context) {
// get blockheight and sdiff which is required by // get blockheight and sdiff which is required by
// txrules.StakePoolTicketFee, and store them in the database // txrules.StakePoolTicketFee, and store them in the database
// for processing by payfee // for processing by payfee
var blockHeader dcrdtypes.GetBlockHeaderVerboseResult blockHeader, err := fWalletClient.GetBlockHeader(ctx, resp.BlockHash)
err = walletClient.Call(ctx, "getblockheader", &blockHeader, resp.BlockHash, true)
if err != nil { if err != nil {
log.Errorf("GetBlockHeader error: %v", err) log.Errorf("GetBlockHeader error: %v", err)
sendErrorResponse("dcrwallet RPC error", http.StatusInternalServerError, c) sendErrorResponse("dcrwallet RPC error", http.StatusInternalServerError, c)
return return
} }
var newAddress string // TODO: Generate this within dcrvsp without an RPC call?
err = walletClient.Call(ctx, "getnewaddress", &newAddress, "fees") newAddress, err := fWalletClient.GetNewAddress(ctx, cfg.FeeAccountName)
if err != nil { if err != nil {
log.Errorf("GetNewAddress error: %v", err) log.Errorf("GetNewAddress error: %v", err)
sendErrorResponse("unable to generate fee address", http.StatusInternalServerError, c) sendErrorResponse("unable to generate fee address", http.StatusInternalServerError, c)

View File

@ -10,10 +10,10 @@ import (
"github.com/decred/dcrd/chaincfg/chainhash" "github.com/decred/dcrd/chaincfg/chainhash"
"github.com/decred/dcrd/dcrec" "github.com/decred/dcrd/dcrec"
"github.com/decred/dcrd/dcrutil/v3" "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/txscript/v3"
"github.com/decred/dcrd/wire" "github.com/decred/dcrd/wire"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/jholdstock/dcrvsp/rpc"
) )
// payFee is the handler for "POST /payfee" // payFee is the handler for "POST /payfee"
@ -131,31 +131,49 @@ findAddress:
return return
} }
walletClient, err := walletRPC() fWalletConn, err := feeWalletConnect()
if err != nil { 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) sendErrorResponse("dcrwallet RPC error", http.StatusInternalServerError, c)
return return
} }
ctx := c.Request.Context() 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 { if err != nil {
log.Errorf("GetRawTransaction failed: %v", err) log.Errorf("Fee wallet client error: %v", err)
sendErrorResponse("dcrwallet RPC error", http.StatusInternalServerError, c) sendErrorResponse("dcrwallet RPC error", http.StatusInternalServerError, c)
return 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 { if err != nil {
log.Errorf("AddTransaction failed: %v", err) log.Errorf("AddTransaction failed: %v", err)
sendErrorResponse("dcrwallet RPC error", http.StatusInternalServerError, c) sendErrorResponse("dcrwallet RPC error", http.StatusInternalServerError, c)
return return
} }
err = walletClient.Call(ctx, "importprivkey", nil, votingWIF.String(), "imported", false, 0) err = vWalletClient.ImportPrivKey(ctx, votingWIF.String())
if err != nil { if err != nil {
log.Errorf("ImportPrivKey failed: %v", err) log.Errorf("ImportPrivKey failed: %v", err)
sendErrorResponse("dcrwallet RPC error", http.StatusInternalServerError, c) sendErrorResponse("dcrwallet RPC error", http.StatusInternalServerError, c)
@ -164,9 +182,9 @@ findAddress:
// Update vote choices on voting wallets. // Update vote choices on voting wallets.
for agenda, choice := range voteChoices { 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 { if err != nil {
log.Errorf("setvotechoice failed: %v", err) log.Errorf("SetVoteChoice failed: %v", err)
sendErrorResponse("dcrwallet RPC error", http.StatusInternalServerError, c) sendErrorResponse("dcrwallet RPC error", http.StatusInternalServerError, c)
return return
} }
@ -181,8 +199,7 @@ findAddress:
return return
} }
var sendTxHash string sendTxHash, err := fWalletClient.SendRawTransaction(ctx, hex.EncodeToString(feeTxBuf.Bytes()))
err = walletClient.Call(ctx, "sendrawtransaction", &sendTxHash, hex.EncodeToString(feeTxBuf.Bytes()), false)
if err != nil { if err != nil {
log.Errorf("SendRawTransaction failed: %v", err) log.Errorf("SendRawTransaction failed: %v", err)
sendErrorResponse("dcrwallet RPC error", http.StatusInternalServerError, c) sendErrorResponse("dcrwallet RPC error", http.StatusInternalServerError, c)

View File

@ -9,6 +9,7 @@ import (
"github.com/decred/dcrd/chaincfg/chainhash" "github.com/decred/dcrd/chaincfg/chainhash"
"github.com/decred/dcrd/dcrutil/v3" "github.com/decred/dcrd/dcrutil/v3"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/jholdstock/dcrvsp/rpc"
) )
// setVoteChoices is the handler for "POST /setvotechoices" // setVoteChoices is the handler for "POST /setvotechoices"
@ -61,25 +62,33 @@ func setVoteChoices(c *gin.Context) {
return return
} }
walletClient, err := walletRPC() vWalletConn, err := votingWalletConnect()
if err != nil { 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) sendErrorResponse("dcrwallet RPC error", http.StatusInternalServerError, c)
return return
} }
ctx := c.Request.Context() 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. // Update vote choices on voting wallets.
for agenda, choice := range voteChoices { 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 { if err != nil {
log.Errorf("setvotechoice failed: %v", err) log.Errorf("SetVoteChoice failed: %v", err)
sendErrorResponse("dcrwallet RPC error", http.StatusInternalServerError, c) sendErrorResponse("dcrwallet RPC error", http.StatusInternalServerError, c)
return return
} }
} }
// TODO: Update database before updating wallets. DB is source of truth and
// is less likely to error.
err = db.UpdateVoteChoices(txHash.String(), voteChoices) err = db.UpdateVoteChoices(txHash.String(), voteChoices)
if err != nil { if err != nil {
log.Errorf("UpdateVoteChoices error: %v", err) log.Errorf("UpdateVoteChoices error: %v", err)

View File

@ -28,10 +28,11 @@ type Config struct {
var cfg Config var cfg Config
var db *database.VspDatabase 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, 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. // Create TCP listener.
var listenConfig net.ListenConfig var listenConfig net.ListenConfig
@ -79,7 +80,8 @@ func Start(ctx context.Context, requestShutdownChan chan struct{}, shutdownWg *s
cfg = config cfg = config
db = vdb db = vdb
walletRPC = wRPC feeWalletConnect = fWalletConnect
votingWalletConnect = vWalletConnect
return nil return nil
} }