From 354785515f18f230d74f3b50d84a5f8f19498f34 Mon Sep 17 00:00:00 2001 From: jholdstock Date: Thu, 18 Dec 2025 11:10:48 +0000 Subject: [PATCH 1/5] rpc: Extract wallet RPC calls to funcs. --- rpc/dcrwallet.go | 53 ++++++++++++++++++++++++++++++------------------ 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/rpc/dcrwallet.go b/rpc/dcrwallet.go index 1b0ad471..695307aa 100644 --- a/rpc/dcrwallet.go +++ b/rpc/dcrwallet.go @@ -56,29 +56,29 @@ 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) + ver, err := walletRPC.version() if err != nil { w.log.Errorf("dcrwallet.Version error (wallet=%s): %v", c.String(), err) failedConnections = append(failedConnections, connect.addr) @@ -86,27 +86,16 @@ func (w *WalletConnect) Clients() ([]*WalletRPC, []string) { 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) { + if !semverCompatible(requiredWalletVersion, *ver) { w.log.Errorf("dcrwallet has incompatible JSON-RPC version (wallet=%s): got %s, expected %s", - c.String(), sVer, requiredWalletVersion) + c.String(), ver, requiredWalletVersion) 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 +111,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 +143,31 @@ func (w *WalletConnect) Clients() ([]*WalletRPC, []string) { return walletClients, failedConnections } +// version uses version RPC to retrieve the API version of dcrwallet. +func (c *WalletRPC) version() (*semver, error) { + var verMap map[string]dcrdtypes.VersionResult + err := c.Call(context.TODO(), "version", &verMap) + if err != nil { + return nil, err + } + + if ver, ok := verMap["dcrwalletjsonrpcapi"]; ok { + return &semver{ver.Major, ver.Minor, ver.Patch}, nil + } + + return nil, fmt.Errorf("response missing %q", "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) { From ddfe0eadabe989657914aae6a82e1a1459002b1a Mon Sep 17 00:00:00 2001 From: jholdstock Date: Wed, 17 Dec 2025 10:58:15 +0000 Subject: [PATCH 2/5] rpc: Extract dcrd RPC calls to funcs. --- rpc/dcrd.go | 65 +++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 46 insertions(+), 19 deletions(-) diff --git a/rpc/dcrd.go b/rpc/dcrd.go index c40bc33a..92abd7ec 100644 --- a/rpc/dcrd.go +++ b/rpc/dcrd.go @@ -74,42 +74,34 @@ 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) + ver, err := dcrdRPC.version() 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) { + if !semverCompatible(requiredDcrdVersion, *ver) { d.client.Close() return nil, d.client.addr, fmt.Errorf("dcrd has incompatible JSON-RPC version: got %s, expected %s", - sVer, requiredDcrdVersion) + ver, 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 +112,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 +124,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 +135,42 @@ func (d *DcrdConnect) Client() (*DcrdRPC, string, error) { return &DcrdRPC{c}, d.client.addr, nil } +// version uses version RPC to retrieve the API version of dcrd. +func (c *DcrdRPC) version() (*semver, error) { + var verMap map[string]dcrdtypes.VersionResult + err := c.Call(context.TODO(), "version", &verMap) + if err != nil { + return nil, err + } + + if ver, ok := verMap["dcrdjsonrpcapi"]; ok { + return &semver{ver.Major, ver.Minor, ver.Patch}, nil + } + + return nil, fmt.Errorf("response missing %q", "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) { From f8d2c1b9bcf184f925954979a1e9f13370870367 Mon Sep 17 00:00:00 2001 From: jholdstock Date: Thu, 18 Dec 2025 11:00:10 +0000 Subject: [PATCH 3/5] rpc: Rename semver.go to version.go. This is in anticipation of the file containing more generic version functionality soon. --- rpc/{semver.go => version.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename rpc/{semver.go => version.go} (100%) diff --git a/rpc/semver.go b/rpc/version.go similarity index 100% rename from rpc/semver.go rename to rpc/version.go From 44d4f601c449f55507f42eac6c6503bbeb578996 Mon Sep 17 00:00:00 2001 From: jholdstock Date: Thu, 18 Dec 2025 11:13:33 +0000 Subject: [PATCH 4/5] rpc: Check dcrd and dcrwallet binary version. Checks were already in place for RPC version, but this was not sufficient to ensure that the binaries have actually been updated. New checks will now enforce the binary and RPC versions of the directly connected dcrd and dcrwallet, and also the dcrd which is backing the connected dcrwallets. --- rpc/dcrd.go | 31 +++++++++++-------------------- rpc/dcrwallet.go | 38 +++++++++++++++----------------------- rpc/version.go | 35 +++++++++++++++++++++++++++++++++-- 3 files changed, 59 insertions(+), 45 deletions(-) diff --git a/rpc/dcrd.go b/rpc/dcrd.go index 92abd7ec..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. @@ -87,19 +83,13 @@ func (d *DcrdConnect) Client() (*DcrdRPC, string, error) { return dcrdRPC, d.client.addr, nil } - // Verify dcrd is at the required api version. - ver, err := dcrdRPC.version() + // 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) } - if !semverCompatible(requiredDcrdVersion, *ver) { - d.client.Close() - return nil, d.client.addr, fmt.Errorf("dcrd has incompatible JSON-RPC version: got %s, expected %s", - ver, requiredDcrdVersion) - } - // Verify dcrd is on the correct network. netID, err := dcrdRPC.getCurrentNet() if err != nil { @@ -135,19 +125,20 @@ func (d *DcrdConnect) Client() (*DcrdRPC, string, error) { return &DcrdRPC{c}, d.client.addr, nil } -// version uses version RPC to retrieve the API version of dcrd. -func (c *DcrdRPC) version() (*semver, error) { +// 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 nil, err - } - - if ver, ok := verMap["dcrdjsonrpcapi"]; ok { - return &semver{ver.Major, ver.Minor, ver.Patch}, nil + return err } - return nil, fmt.Errorf("response missing %q", "dcrdjsonrpcapi") + return errors.Join( + checkVersion(verMap, "dcrd"), + checkVersion(verMap, "dcrdjsonrpcapi"), + ) } // getCurrentNet uses getcurrentnet RPC to return the Decred network the wallet diff --git a/rpc/dcrwallet.go b/rpc/dcrwallet.go index 695307aa..e7f5f265 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 { @@ -77,18 +74,10 @@ func (w *WalletConnect) Clients() ([]*WalletRPC, []string) { continue } - // Verify dcrwallet is at the required api version. - ver, err := walletRPC.version() + // 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 - } - - if !semverCompatible(requiredWalletVersion, *ver) { - w.log.Errorf("dcrwallet has incompatible JSON-RPC version (wallet=%s): got %s, expected %s", - c.String(), ver, requiredWalletVersion) + w.log.Errorf("Version check failed (wallet=%s): %v", c.String(), err) failedConnections = append(failedConnections, connect.addr) connect.Close() continue @@ -143,19 +132,22 @@ func (w *WalletConnect) Clients() ([]*WalletRPC, []string) { return walletClients, failedConnections } -// version uses version RPC to retrieve the API version of dcrwallet. -func (c *WalletRPC) version() (*semver, error) { +// 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 nil, err - } - - if ver, ok := verMap["dcrwalletjsonrpcapi"]; ok { - return &semver{ver.Major, ver.Minor, ver.Patch}, nil + return err } - return nil, fmt.Errorf("response missing %q", "dcrwalletjsonrpcapi") + 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. diff --git a/rpc/version.go b/rpc/version.go index 153f73a3..6da16be8 100644 --- a/rpc/version.go +++ b/rpc/version.go @@ -1,10 +1,41 @@ -// Copyright (c) 2020 The Decred developers +// 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" +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 From 5b21dd42d573dacfe1aeae14668f55439df2fd8d Mon Sep 17 00:00:00 2001 From: jholdstock Date: Thu, 18 Dec 2025 11:18:06 +0000 Subject: [PATCH 5/5] docs: Explicitly prohibit running SPV wallets. It was already enforced by version checks in the code but not called out in the docs. --- docs/deployment.md | 21 ++++++++++----------- rpc/dcrwallet.go | 2 ++ 2 files changed, 12 insertions(+), 11 deletions(-) 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/dcrwallet.go b/rpc/dcrwallet.go index e7f5f265..b97aebfb 100644 --- a/rpc/dcrwallet.go +++ b/rpc/dcrwallet.go @@ -142,6 +142,8 @@ func (c *WalletRPC) checkVersions() error { 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"),