diff --git a/docs/deployment.md b/docs/deployment.md index 689b5289..45d34fc4 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -69,17 +69,16 @@ A vspd deployment should have a minimum of three remote voting wallets. The servers hosting these wallets should ideally be in geographically separate locations. -Each voting server should be running an instance of dcrd and dcrwallet. The -wallet on these servers should be completely empty and not used for any purpose -other than voting tickets added by vspd. -dcrwallet should be permanently unlocked and have voting enabled -(`--enablevoting`). dcrwallet is also required to have the manual tickets -option (`--manualtickets`) enabled which disables dcrwallet adding tickets -arriving over the network. -This prevents a user from reusing a voting address and the VSP voting multiple -tickets with only a single fee payment. -vspd on the front-end server must be able to reach each instance of dcrwallet -over RPC. +Each voting server should be running an instance of dcrwallet backed by an +instance of dcrd (dcrwallet in SPV mode is not supported). +The wallet on these servers should be completely empty and not +used for any purpose other than voting tickets added by vspd. dcrwallet should +be permanently unlocked and have voting enabled (`--enablevoting`). dcrwallet is +also required to have the manual tickets option (`--manualtickets`) enabled +which disables dcrwallet adding tickets arriving over the network. This prevents +a user from reusing a voting address and the VSP voting multiple tickets with +only a single fee payment. vspd on the front-end server must be able to reach +each instance of dcrwallet over RPC. ## Front-end Server diff --git a/rpc/dcrd.go b/rpc/dcrd.go index c40bc33a..76a54929 100644 --- a/rpc/dcrd.go +++ b/rpc/dcrd.go @@ -24,10 +24,6 @@ import ( "github.com/jrick/wsrpc/v2" ) -var ( - requiredDcrdVersion = semver{Major: 8, Minor: 3, Patch: 0} -) - const ( // These numerical error codes are defined in dcrd/dcrjson. Copied here so // we dont need to import the whole package. @@ -74,42 +70,28 @@ func (d *DcrdConnect) Close() { // Client creates a new DcrdRPC client instance. Returns an error if dialing // dcrd fails or if dcrd is misconfigured. func (d *DcrdConnect) Client() (*DcrdRPC, string, error) { - ctx := context.TODO() - c, newConnection, err := d.client.dial(ctx) + c, newConnection, err := d.client.dial(context.TODO()) if err != nil { return nil, d.client.addr, fmt.Errorf("dcrd dial error: %w", err) } + dcrdRPC := &DcrdRPC{c} + // If this is a reused connection, we don't need to validate the dcrd config // again. if !newConnection { - return &DcrdRPC{c}, d.client.addr, nil + return dcrdRPC, d.client.addr, nil } - // Verify dcrd is at the required api version. - var verMap map[string]dcrdtypes.VersionResult - err = c.Call(ctx, "version", &verMap) + // Verify dcrd is at the required version. + err = dcrdRPC.checkVersion() if err != nil { d.client.Close() return nil, d.client.addr, fmt.Errorf("dcrd version check failed: %w", err) } - ver, exists := verMap["dcrdjsonrpcapi"] - if !exists { - d.client.Close() - return nil, d.client.addr, fmt.Errorf("dcrd version response missing 'dcrdjsonrpcapi'") - } - - sVer := semver{ver.Major, ver.Minor, ver.Patch} - if !semverCompatible(requiredDcrdVersion, sVer) { - d.client.Close() - return nil, d.client.addr, fmt.Errorf("dcrd has incompatible JSON-RPC version: got %s, expected %s", - sVer, requiredDcrdVersion) - } - // Verify dcrd is on the correct network. - var netID wire.CurrencyNet - err = c.Call(ctx, "getcurrentnet", &netID) + netID, err := dcrdRPC.getCurrentNet() if err != nil { d.client.Close() return nil, d.client.addr, fmt.Errorf("dcrd getcurrentnet check failed: %w", err) @@ -120,8 +102,7 @@ func (d *DcrdConnect) Client() (*DcrdRPC, string, error) { } // Verify dcrd has tx index enabled (required for getrawtransaction). - var info dcrdtypes.InfoChainResult - err = c.Call(ctx, "getinfo", &info) + info, err := dcrdRPC.getInfo() if err != nil { d.client.Close() return nil, d.client.addr, fmt.Errorf("dcrd getinfo check failed: %w", err) @@ -133,7 +114,7 @@ func (d *DcrdConnect) Client() (*DcrdRPC, string, error) { // Request blockconnected notifications. if d.client.notifier != nil { - err = c.Call(ctx, "notifyblocks", nil) + err = dcrdRPC.NotifyBlocks() if err != nil { return nil, d.client.addr, fmt.Errorf("notifyblocks failed: %w", err) } @@ -144,6 +125,43 @@ func (d *DcrdConnect) Client() (*DcrdRPC, string, error) { return &DcrdRPC{c}, d.client.addr, nil } +// checkVersion uses version RPC to retrieve the binary and API version of dcrd. +// An error is returned if there is not semver compatibility with the minimum +// expected versions. +func (c *DcrdRPC) checkVersion() error { + var verMap map[string]dcrdtypes.VersionResult + err := c.Call(context.TODO(), "version", &verMap) + if err != nil { + return err + } + + return errors.Join( + checkVersion(verMap, "dcrd"), + checkVersion(verMap, "dcrdjsonrpcapi"), + ) +} + +// getCurrentNet uses getcurrentnet RPC to return the Decred network the wallet +// is connected to. +func (c *DcrdRPC) getCurrentNet() (wire.CurrencyNet, error) { + var netID wire.CurrencyNet + err := c.Call(context.TODO(), "getcurrentnet", &netID) + if err != nil { + return 0, err + } + return netID, nil +} + +// getInfo uses getinfo RPC to return various daemon, network, and chain info. +func (c *DcrdRPC) getInfo() (*dcrdtypes.InfoChainResult, error) { + var info dcrdtypes.InfoChainResult + err := c.Call(context.TODO(), "getinfo", &info) + if err != nil { + return nil, err + } + return &info, nil +} + // GetRawTransaction uses getrawtransaction RPC to retrieve details about the // transaction with the provided hash. func (c *DcrdRPC) GetRawTransaction(txHash string) (*dcrdtypes.TxRawResult, error) { diff --git a/rpc/dcrwallet.go b/rpc/dcrwallet.go index 1b0ad471..b97aebfb 100644 --- a/rpc/dcrwallet.go +++ b/rpc/dcrwallet.go @@ -6,6 +6,7 @@ package rpc import ( "context" + "errors" "fmt" wallettypes "decred.org/dcrwallet/v5/rpc/jsonrpc/types" @@ -15,10 +16,6 @@ import ( "github.com/decred/slog" ) -var ( - requiredWalletVersion = semver{Major: 11, Minor: 0, Patch: 0} -) - // WalletRPC provides methods for calling dcrwallet JSON-RPCs without exposing the details // of JSON encoding. type WalletRPC struct { @@ -56,57 +53,38 @@ func (w *WalletConnect) Close() { // increments a count of failed connections if a connection cannot be // established, or if the wallet is misconfigured. func (w *WalletConnect) Clients() ([]*WalletRPC, []string) { - ctx := context.TODO() walletClients := make([]*WalletRPC, 0) failedConnections := make([]string, 0) for _, connect := range w.clients { - c, newConnection, err := connect.dial(ctx) + c, newConnection, err := connect.dial(context.TODO()) if err != nil { w.log.Errorf("dcrwallet dial error: %v", err) failedConnections = append(failedConnections, connect.addr) continue } + walletRPC := &WalletRPC{c} + // If this is a reused connection, we don't need to validate the // dcrwallet config again. if !newConnection { - walletClients = append(walletClients, &WalletRPC{c}) + walletClients = append(walletClients, walletRPC) continue } - // Verify dcrwallet is at the required api version. - var verMap map[string]dcrdtypes.VersionResult - err = c.Call(ctx, "version", &verMap) + // Verify dcrwallet and dcrd are at the required versions. + err = walletRPC.checkVersions() if err != nil { - w.log.Errorf("dcrwallet.Version error (wallet=%s): %v", c.String(), err) - failedConnections = append(failedConnections, connect.addr) - connect.Close() - continue - } - - ver, exists := verMap["dcrwalletjsonrpcapi"] - if !exists { - w.log.Errorf("dcrwallet.Version response missing 'dcrwalletjsonrpcapi' (wallet=%s)", - c.String()) - failedConnections = append(failedConnections, connect.addr) - connect.Close() - continue - } - - sVer := semver{ver.Major, ver.Minor, ver.Patch} - if !semverCompatible(requiredWalletVersion, sVer) { - w.log.Errorf("dcrwallet has incompatible JSON-RPC version (wallet=%s): got %s, expected %s", - c.String(), sVer, requiredWalletVersion) + w.log.Errorf("Version check failed (wallet=%s): %v", c.String(), err) failedConnections = append(failedConnections, connect.addr) connect.Close() continue } // Verify dcrwallet is on the correct network. - var netID wire.CurrencyNet - err = c.Call(ctx, "getcurrentnet", &netID) + netID, err := walletRPC.getCurrentNet() if err != nil { w.log.Errorf("dcrwallet.GetCurrentNet error (wallet=%s): %v", c.String(), err) failedConnections = append(failedConnections, connect.addr) @@ -122,7 +100,6 @@ func (w *WalletConnect) Clients() ([]*WalletRPC, []string) { } // Verify dcrwallet is voting and unlocked. - walletRPC := &WalletRPC{c} walletInfo, err := walletRPC.WalletInfo() if err != nil { w.log.Errorf("dcrwallet.WalletInfo error (wallet=%s): %v", c.String(), err) @@ -155,6 +132,36 @@ func (w *WalletConnect) Clients() ([]*WalletRPC, []string) { return walletClients, failedConnections } +// checkVersion uses version RPC to retrieve the binary and API versions +// dcrwallet and its backing dcrd. An error is returned if there is not semver +// compatibility with the minimum expected versions. +func (c *WalletRPC) checkVersions() error { + var verMap map[string]dcrdtypes.VersionResult + err := c.Call(context.TODO(), "version", &verMap) + if err != nil { + return err + } + + // Presence of dcrd and dcrdjsonrpcapi in this map confirms dcrwallet is not + // running in SPV mode. + return errors.Join( + checkVersion(verMap, "dcrd"), + checkVersion(verMap, "dcrdjsonrpcapi"), + checkVersion(verMap, "dcrwallet"), + checkVersion(verMap, "dcrwalletjsonrpcapi"), + ) +} + +// getCurrentNet returns the Decred network the wallet is connected to. +func (c *WalletRPC) getCurrentNet() (wire.CurrencyNet, error) { + var netID wire.CurrencyNet + err := c.Call(context.TODO(), "getcurrentnet", &netID) + if err != nil { + return 0, err + } + return netID, nil +} + // WalletInfo uses walletinfo RPC to retrieve information about how the // dcrwallet instance is configured. func (c *WalletRPC) WalletInfo() (*wallettypes.WalletInfoResult, error) { diff --git a/rpc/semver.go b/rpc/semver.go deleted file mode 100644 index 153f73a3..00000000 --- a/rpc/semver.go +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) 2020 The Decred developers -// Use of this source code is governed by an ISC -// license that can be found in the LICENSE file. - -package rpc - -import "fmt" - -type semver struct { - Major uint32 - Minor uint32 - Patch uint32 -} - -func semverCompatible(required, actual semver) bool { - switch { - case required.Major != actual.Major: - return false - case required.Minor > actual.Minor: - return false - case required.Minor == actual.Minor && required.Patch > actual.Patch: - return false - default: - return true - } -} - -func (s semver) String() string { - return fmt.Sprintf("%d.%d.%d", s.Major, s.Minor, s.Patch) -} diff --git a/rpc/version.go b/rpc/version.go new file mode 100644 index 00000000..6da16be8 --- /dev/null +++ b/rpc/version.go @@ -0,0 +1,61 @@ +// Copyright (c) 2020-2025 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package rpc + +import ( + "fmt" + + dcrdtypes "github.com/decred/dcrd/rpc/jsonrpc/types/v4" +) + +// minimumVersions contains the minimum expected binary and API versions for +// dcrd and dcrwallet. +var minimumVersions = map[string]semver{ + "dcrd": {Major: 2, Minor: 1}, + "dcrdjsonrpcapi": {Major: 8, Minor: 3}, + "dcrwallet": {Major: 2, Minor: 1}, + "dcrwalletjsonrpcapi": {Major: 11, Minor: 0}, +} + +// checkVersion returns an error if the provided key in verMap does not have +// semver compatibility with the minimum expected versions. +func checkVersion(verMap map[string]dcrdtypes.VersionResult, key string) error { + var actualV semver + if ver, ok := verMap[key]; ok { + actualV = semver{ver.Major, ver.Minor, ver.Patch} + } else { + return fmt.Errorf("version map missing key %q", key) + } + + minimumV := minimumVersions[key] + if !semverCompatible(minimumV, actualV) { + return fmt.Errorf("incompatible %q version, expected %s got %s", + key, minimumV, actualV) + } + return nil +} + +type semver struct { + Major uint32 + Minor uint32 + Patch uint32 +} + +func semverCompatible(required, actual semver) bool { + switch { + case required.Major != actual.Major: + return false + case required.Minor > actual.Minor: + return false + case required.Minor == actual.Minor && required.Patch > actual.Patch: + return false + default: + return true + } +} + +func (s semver) String() string { + return fmt.Sprintf("%d.%d.%d", s.Major, s.Minor, s.Patch) +}