From 30516a66b790068e735be1070b2fc34199a8f6a2 Mon Sep 17 00:00:00 2001 From: Matheus Degiovani Date: Thu, 21 Dec 2023 08:35:53 -0300 Subject: [PATCH] dcrwtest: Initial Version. The dcrwtest module allows running a dcrwallet instance. It supports creating and opening wallets, syncing the wallet with with a dcrd node (in either RPC or SPV mode) and operating the wallet through its gRPC and JSON-RPC interfaces. --- README.md | 3 + dcrtest.work | 1 + dcrwtest/README.md | 55 +++ dcrwtest/builder.go | 128 ++++++ dcrwtest/builder_test.go | 34 ++ dcrwtest/go.mod | 64 +++ dcrwtest/go.sum | 115 +++++ dcrwtest/grpc.go | 202 +++++++++ dcrwtest/ipc.go | 119 +++++ dcrwtest/jsonrpc.go | 179 ++++++++ dcrwtest/log.go | 17 + dcrwtest/remotewallet.go | 732 +++++++++++++++++++++++++++++++ dcrwtest/remotewallet_common.go | 27 ++ dcrwtest/remotewallet_test.go | 233 ++++++++++ dcrwtest/remotewallet_windows.go | 30 ++ dcrwtest/require.go | 13 + 16 files changed, 1952 insertions(+) create mode 100644 dcrwtest/README.md create mode 100644 dcrwtest/builder.go create mode 100644 dcrwtest/builder_test.go create mode 100644 dcrwtest/go.mod create mode 100644 dcrwtest/go.sum create mode 100644 dcrwtest/grpc.go create mode 100644 dcrwtest/ipc.go create mode 100644 dcrwtest/jsonrpc.go create mode 100644 dcrwtest/log.go create mode 100644 dcrwtest/remotewallet.go create mode 100644 dcrwtest/remotewallet_common.go create mode 100644 dcrwtest/remotewallet_test.go create mode 100644 dcrwtest/remotewallet_windows.go create mode 100644 dcrwtest/require.go diff --git a/README.md b/README.md index 1860e6b..aaa15ef 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,9 @@ The following sub-modules are currently provided: - [`dcrdtest`](./dcrdtest): Provides testing facilities to use [`dcrd`](https://github.com/decred/dcrd) binaries in a simnet network. + - [`dcrwtest`](./dcrwtest): Provides automation facilities to use + [`dcrwallet`](https://github.com/decred/dcrwallet) binaries in testing or + other autmated scenarios. ## License diff --git a/dcrtest.work b/dcrtest.work index 87777ea..3c508f7 100644 --- a/dcrtest.work +++ b/dcrtest.work @@ -1,3 +1,4 @@ go 1.19 use ./dcrdtest +use ./dcrwtest diff --git a/dcrwtest/README.md b/dcrwtest/README.md new file mode 100644 index 0000000..2611a27 --- /dev/null +++ b/dcrwtest/README.md @@ -0,0 +1,55 @@ +dcrwtest +======= + +[![Build Status](https://github.com/decred/dcrtest/workflows/Build%20and%20Test/badge.svg)](https://github.com/decred/dcrtest/actions) +[![ISC License](https://img.shields.io/badge/license-ISC-blue.svg)](http://copyfree.org) +[![Doc](https://img.shields.io/badge/doc-reference-blue.svg)](https://pkg.go.dev/github.com/decred/dcrtest/dcrwtest) + +Package dcrwtest provides a dcrwallet-specific automation and testing +harness. This allows creating, running and operating a decred wallet through its +JSON-RPC and gRPC interfaces. + +This package was designed specifically to act as a testing harness for +`dcrwallet`. However, the constructs presented are general enough to be adapted +to any project wishing to programmatically drive a `dcrwallet` instance of its +systems/integration tests. + +## Installation and Updating + +```shell +$ go get github.com/decred/dcrtest/dcrwtest@latest +``` + +## Choice of dcrwallet Binary + +This library requires a `dcrwallet` binary to be available for running and +driving its operations. The specific binary that is used can be selected in two +ways: + +### Manually + +Using the package-level `SetPathToDcrwallet()` function, users of `dcrwtest` can +specify a path to a pre-existing binary. This binary must exist and have +executable permissions for the current user. + +### Automatically + +When a path to an existing `dcrwallet` binary is not defined via +`SetPathToDcrwallet()`, then this package will attempt to build one in a +temporary directory. This requires the Go toolchain to be available for the +current user. + +The version of the `dcrwallet` binary that will be built will be chosen +following the rules for the standard Go toolchain module version determination: + + 1. The version or replacement specified in the currently active [Go + Workspace](https://go.dev/ref/mod#workspaces). + 2. The version or replacement specified in the main module (i.e. in the + `go.mod` of the project that imports this package). + 3. The version specified in the [go.mod](./go.mod) of this package. + +## License + +Package dcrwtest is licensed under the [copyfree](http://copyfree.org) ISC +License. + diff --git a/dcrwtest/builder.go b/dcrwtest/builder.go new file mode 100644 index 0000000..196ab70 --- /dev/null +++ b/dcrwtest/builder.go @@ -0,0 +1,128 @@ +// Copyright (c) 2023 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. +package dcrwtest + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "sync" +) + +var ( + // pathToDcrwMtx protects the following fields. + pathToDcrwMtx sync.RWMutex + + // pathToDcrw points to the test dcrwallet binary. It is supplied + // through NewWithDCRW or created on the first call to New and used + // throughout the life of this package. + pathToDcrw string + + // isBuiltBinary tracks whether the dcrwallet binary pointed to by + // pathToDcrw was built by an invocation of globalPathToDcw(). + isBuiltBinary bool + + // dcrwMainPkg is the main dcrwallet package version. + // + // NOTE: this MUST have the same value as the one required in + // require.go. + dcrwMainPkg = "decred.org/dcrwallet/v3" +) + +// SetPathToDcrwallet sets the package level dcrwallet executable. All calls to +// New will use the dcrwallet located there throughout their life. If not set +// upon the first call to New, a dcrwallet will be created in a temporary +// directory and pathToDCRW set automatically. +// +// NOTE: This function is safe for concurrent access, but care must be taken +// when setting different paths and using New, as whatever is at pathToDCRW at +// the time will be used to start that instance. +func SetPathToDcrwallet(path string) { + pathToDcrwMtx.Lock() + pathToDcrw = path + isBuiltBinary = false + pathToDcrwMtx.Unlock() +} + +// SetDcrwalletMainPkg sets the version of the main dcrwallet package executable. +// Calls to New that require building a fresh dcrwallet instance will cause +// the specified package to be built. +// +// NOTE: This function is safe for concurrent access, but care must be taken +// when setting different packages and using New, as the value of the package +// set when New() is executed will be used. +func SetDcrwalletMainPkg(pkg string) { + pathToDcrwMtx.Lock() + dcrwMainPkg = pkg + pathToDcrw = "" + isBuiltBinary = false + pathToDcrwMtx.Unlock() +} + +// buildDcrw builds a dcrwallet binary in a temp file and returns the path to +// the binary. This requires the Go toolchain to be installed and available in +// the machine. The version of the dcrwallet package built depends on the +// currently required version of the decred.org/dcrwallet module, which may be +// defined by either the go.mod file in this package, a main go.mod file (when +// this package is included as a library in a project) or the current +// workspace. +func buildDcrw(dcrwMainPkg string) (string, error) { + // NOTE: when updating this package, the dummy import in require.go + // MUST also be updated. + outDir, err := os.MkdirTemp("", "dcrwtestdcrwallet") + if err != nil { + return "", err + } + + dcrwalletBin := "dcrwallet" + if runtime.GOOS == "windows" { + dcrwalletBin += ".exe" + } + + dcrwPath := filepath.Join(outDir, dcrwalletBin) + log.Debugf("Building dcrwallet pkg %s in %s", dcrwMainPkg, dcrwPath) + cmd := exec.Command("go", "build", "-o", dcrwPath, dcrwMainPkg) + output, err := cmd.CombinedOutput() + if err != nil { + log.Error(string(output)) + return "", fmt.Errorf("failed to build dcrwallet: %w", err) + } + + return dcrwPath, nil +} + +// globalPathToDcrw returns the global path to the binary dcrwallet instance. +// If needed, this will attempt to build a test instance of dcrwallet. +func globalPathToDcrw() (string, error) { + pathToDcrwMtx.Lock() + defer pathToDcrwMtx.Unlock() + if pathToDcrw != "" { + return pathToDcrw, nil + } + + newPath, err := buildDcrw(dcrwMainPkg) + if err != nil { + return "", err + } + pathToDcrw = newPath + isBuiltBinary = true + return newPath, nil +} + +// CleanBuiltDcrwallet cleans the currently used dcrwallet binary dir if it was +// built by this package. +func CleanBuiltDcrwallet() error { + var err error + pathToDcrwMtx.Lock() + if isBuiltBinary && pathToDcrw != "" { + isBuiltBinary = false + err = os.RemoveAll(filepath.Dir(pathToDcrw)) + pathToDcrw = "" + } + pathToDcrwMtx.Unlock() + return err + +} diff --git a/dcrwtest/builder_test.go b/dcrwtest/builder_test.go new file mode 100644 index 0000000..49cf6e3 --- /dev/null +++ b/dcrwtest/builder_test.go @@ -0,0 +1,34 @@ +// Copyright (c) 2023 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. +package dcrwtest + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +// TestBuilder tests that we can build a new dcrwallet binary. +func TestBuilder(t *testing.T) { + path, err := buildDcrw(dcrwMainPkg) + if err != nil { + t.Fatalf("Unable to build dcrwallet: %v", err) + } + + t.Logf("Built dcrwallet at %s", path) + + cmd := exec.Command(path, "--version") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("Unable to fetch dcrwallet output: %v", err) + } + + t.Logf("dcrwallet version: %s", string(output)) + + err = os.RemoveAll(filepath.Dir(path)) + if err != nil { + t.Fatal(err) + } +} diff --git a/dcrwtest/go.mod b/dcrwtest/go.mod new file mode 100644 index 0000000..6a81e44 --- /dev/null +++ b/dcrwtest/go.mod @@ -0,0 +1,64 @@ +module github.com/decred/dcrtest/dcrwtest + +go 1.19 + +// The following require defines the version of dcrwallet that is built for +// tests of this package and the minimum version used when this package is +// required by a client module (unless overridden in the main module or +// workspace). +require decred.org/dcrwallet/v3 v3.0.0 + +require ( + github.com/decred/dcrd/chaincfg/v3 v3.2.0 + github.com/decred/dcrd/dcrjson/v4 v4.0.1 + github.com/decred/dcrd/rpcclient/v8 v8.0.0 + github.com/decred/dcrd/wire v1.6.0 + github.com/decred/slog v1.2.0 + github.com/jrick/wsrpc/v2 v2.3.5 + golang.org/x/net v0.9.0 + golang.org/x/sync v0.5.0 + google.golang.org/grpc v1.54.0 + matheusd.com/testctx v0.1.0 +) + +require ( + decred.org/cspp/v2 v2.1.0 // indirect + github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412 // indirect + github.com/companyzero/sntrup4591761 v0.0.0-20220309191932-9e0f3af2f07a // indirect + github.com/dchest/siphash v1.2.3 // indirect + github.com/decred/base58 v1.0.5 // indirect + github.com/decred/dcrd/addrmgr/v2 v2.0.2 // indirect + github.com/decred/dcrd/blockchain/stake/v5 v5.0.0 // indirect + github.com/decred/dcrd/blockchain/standalone/v2 v2.2.0 // indirect + github.com/decred/dcrd/certgen v1.1.2 // indirect + github.com/decred/dcrd/chaincfg/chainhash v1.0.4 // indirect + github.com/decred/dcrd/connmgr/v3 v3.1.1 // indirect + github.com/decred/dcrd/crypto/blake256 v1.0.1 // indirect + github.com/decred/dcrd/crypto/ripemd160 v1.0.2 // indirect + github.com/decred/dcrd/database/v3 v3.0.1 // indirect + github.com/decred/dcrd/dcrec v1.0.1 // indirect + github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect + github.com/decred/dcrd/dcrutil/v4 v4.0.1 // indirect + github.com/decred/dcrd/gcs/v4 v4.0.0 // indirect + github.com/decred/dcrd/hdkeychain/v3 v3.1.1 // indirect + github.com/decred/dcrd/rpc/jsonrpc/types/v4 v4.1.0 // indirect + github.com/decred/dcrd/txscript/v4 v4.1.0 // indirect + github.com/decred/go-socks v1.1.0 // indirect + github.com/decred/vspd/client/v2 v2.0.0 // indirect + github.com/decred/vspd/types/v2 v2.1.0 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/gorilla/websocket v1.5.0 // indirect + github.com/jessevdk/go-flags v1.5.0 // indirect + github.com/jrick/bitset v1.0.0 // indirect + github.com/jrick/logrotate v1.0.0 // indirect + github.com/klauspost/cpuid/v2 v2.2.5 // indirect + go.etcd.io/bbolt v1.3.7 // indirect + golang.org/x/crypto v0.7.0 // indirect + golang.org/x/sys v0.12.0 // indirect + golang.org/x/term v0.7.0 // indirect + golang.org/x/text v0.9.0 // indirect + google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect + google.golang.org/protobuf v1.30.0 // indirect + lukechampine.com/blake3 v1.2.1 // indirect +) diff --git a/dcrwtest/go.sum b/dcrwtest/go.sum new file mode 100644 index 0000000..1c589a4 --- /dev/null +++ b/dcrwtest/go.sum @@ -0,0 +1,115 @@ +decred.org/cspp/v2 v2.1.0 h1:HeHb9+BFqrBaAPc6CsPiUpPFmC1uyBM2mJZUAbUXkRw= +decred.org/cspp/v2 v2.1.0/go.mod h1:9nO3bfvCheOPIFZw5f6sRQ42CjBFB5RKSaJ9Iq6G4MA= +decred.org/dcrwallet/v3 v3.0.0 h1:EdI7D9U7fnvfoWexWvlhPNri+Ws9RdvjP0A5Sc38TWs= +decred.org/dcrwallet/v3 v3.0.0/go.mod h1:a+R8BZIOKVpWVPat5VZoBWNh/cnIciwcRkPtrzfS/tw= +github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412 h1:w1UutsfOrms1J05zt7ISrnJIXKzwaspym5BTKGx93EI= +github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412/go.mod h1:WPjqKcmVOxf0XSf3YxCJs6N6AOSrOx3obionmG7T0y0= +github.com/companyzero/sntrup4591761 v0.0.0-20220309191932-9e0f3af2f07a h1:clYxJ3Os0EQUKDDVU8M0oipllX0EkuFNBfhVQuIfyF0= +github.com/companyzero/sntrup4591761 v0.0.0-20220309191932-9e0f3af2f07a/go.mod h1:z/9Ck1EDixEbBbZ2KH2qNHekEmDLTOZ+FyoIPWWSVOI= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA= +github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc= +github.com/decred/base58 v1.0.5 h1:hwcieUM3pfPnE/6p3J100zoRfGkQxBulZHo7GZfOqic= +github.com/decred/base58 v1.0.5/go.mod h1:s/8lukEHFA6bUQQb/v3rjUySJ2hu+RioCzLukAVkrfw= +github.com/decred/dcrd/addrmgr/v2 v2.0.2 h1:h7PF1FoWcGUBcOhon7hK4Du7gT4KJb2/dCC4SVVnvgI= +github.com/decred/dcrd/addrmgr/v2 v2.0.2/go.mod h1:lMupOhByAzVJN7EFWSGLeGTrlvvx38uCY4D+bPf2AT4= +github.com/decred/dcrd/blockchain/stake/v5 v5.0.0 h1:WyxS8zMvTMpC5qYC9uJY+UzuV/x9ko4z20qBtH5Hzzs= +github.com/decred/dcrd/blockchain/stake/v5 v5.0.0/go.mod h1:5sSjMq9THpnrLkW0SjEqIBIo8qq2nXzc+m7k9oFVVmY= +github.com/decred/dcrd/blockchain/standalone/v2 v2.2.0 h1:v3yfo66axjr3oLihct+5tLEeM9YUzvK3i/6e2Im6RO0= +github.com/decred/dcrd/blockchain/standalone/v2 v2.2.0/go.mod h1:JsOpl2nHhW2D2bWMEtbMuAE+mIU/Pdd1i1pmYR+2RYI= +github.com/decred/dcrd/blockchain/v5 v5.0.0 h1:eAI9zbNpCFR6Xik6RLUEijAL3BO4QVJQ0Az3sz7ZGqk= +github.com/decred/dcrd/certgen v1.1.2 h1:6gvI74y9+IGUHPSXv1bWcAhGvT4Enm1Z808lyhLqqrs= +github.com/decred/dcrd/certgen v1.1.2/go.mod h1:Od5y39J+r2ZlvrizyWu2cylcYu0+emTTVm3eix4W8bw= +github.com/decred/dcrd/chaincfg/chainhash v1.0.4 h1:zRCv6tdncLfLTKYqu7hrXvs7hW+8FO/NvwoFvGsrluU= +github.com/decred/dcrd/chaincfg/chainhash v1.0.4/go.mod h1:hA86XxlBWwHivMvxzXTSD0ZCG/LoYsFdWnCekkTMCqY= +github.com/decred/dcrd/chaincfg/v3 v3.2.0 h1:6WxA92AGBkycEuWvxtZMvA76FbzbkDRoK8OGbsR2muk= +github.com/decred/dcrd/chaincfg/v3 v3.2.0/go.mod h1:2rHW1TKyFmwZTVBLoU/Cmf0oxcpBjUEegbSlBfrsriI= +github.com/decred/dcrd/connmgr/v3 v3.1.1 h1:si7bgYlyeSbB0Ewe+bfoO/RAzxuPwPkL40DDZhyITmo= +github.com/decred/dcrd/connmgr/v3 v3.1.1/go.mod h1:YlRGPagi/6SJbG9CFq2ZnorX9+deRNb6+m0ovkNDcKY= +github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5ilcvdfma9wOH6Y= +github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/crypto/ripemd160 v1.0.2 h1:TvGTmUBHDU75OHro9ojPLK+Yv7gDl2hnUvRocRCjsys= +github.com/decred/dcrd/crypto/ripemd160 v1.0.2/go.mod h1:uGfjDyePSpa75cSQLzNdVmWlbQMBuiJkvXw/MNKRY4M= +github.com/decred/dcrd/database/v3 v3.0.1 h1:oaklASAsUBwDoRgaS961WYqecFMZNhI1k+BmGgeW7/U= +github.com/decred/dcrd/database/v3 v3.0.1/go.mod h1:IErr/Z62pFLoPZTMPGxedbcIuseGk0w3dszP3AFbXyw= +github.com/decred/dcrd/dcrec v1.0.1 h1:gDzlndw0zYxM5BlaV17d7ZJV6vhRe9njPBFeg4Db2UY= +github.com/decred/dcrd/dcrec v1.0.1/go.mod h1:CO+EJd8eHFb8WHa84C7ZBkXsNUIywaTHb+UAuI5uo6o= +github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3 h1:l/lhv2aJCUignzls81+wvga0TFlyoZx8QxRMQgXpZik= +github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3/go.mod h1:AKpV6+wZ2MfPRJnTbQ6NPgWrKzbe9RCIlCF/FKzMtM8= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= +github.com/decred/dcrd/dcrjson/v4 v4.0.1 h1:vyQuB1miwGqbCVNm8P6br3V65WQ6wyrh0LycMkvaBBg= +github.com/decred/dcrd/dcrjson/v4 v4.0.1/go.mod h1:2qVikafVF9/X3PngQVmqkbUbyAl32uik0k/kydgtqMc= +github.com/decred/dcrd/dcrutil/v4 v4.0.1 h1:E+d2TNbpOj0f1L9RqkZkEm1QolFjajvkzxWC5WOPf1s= +github.com/decred/dcrd/dcrutil/v4 v4.0.1/go.mod h1:7EXyHYj8FEqY+WzMuRkF0nh32ueLqhutZDoW4eQ+KRc= +github.com/decred/dcrd/gcs/v4 v4.0.0 h1:bet+Ax1ZFUqn2M0g1uotm0b8F6BZ9MmblViyJ088E8k= +github.com/decred/dcrd/gcs/v4 v4.0.0/go.mod h1:9z+EBagzpEdAumwS09vf/hiGaR8XhNmsBgaVq6u7/NI= +github.com/decred/dcrd/hdkeychain/v3 v3.1.1 h1:4WhyHNBy7ec6qBUC7Fq7JFVGSd7bpuR5H+AJRID8Lyk= +github.com/decred/dcrd/hdkeychain/v3 v3.1.1/go.mod h1:HaabrLc27lnny5/Ph9+6I3szp0op5MCb7smEwlzfD60= +github.com/decred/dcrd/rpc/jsonrpc/types/v4 v4.1.0 h1:kQFK7FMTmMDX9amyhh8IR0vwwI8dH0KCBm42C64bWVs= +github.com/decred/dcrd/rpc/jsonrpc/types/v4 v4.1.0/go.mod h1:dDHO7ivrPAhZjFD3LoOJN/kdq5gi0sxie6zCsWHAiUo= +github.com/decred/dcrd/rpcclient/v8 v8.0.0 h1:O4B5d+8e2OjbeFW+c1XcZNQzyp++04ArWhXgYrsURus= +github.com/decred/dcrd/rpcclient/v8 v8.0.0/go.mod h1:gx4+DI5apuOEeLwPBJFlMoj3GFWq1I7/X8XCQmMTi8Q= +github.com/decred/dcrd/txscript/v4 v4.1.0 h1:uEdcibIOl6BuWj3AqmXZ9xIK/qbo6lHY9aNk29FtkrU= +github.com/decred/dcrd/txscript/v4 v4.1.0/go.mod h1:OVguPtPc4YMkgssxzP8B6XEMf/J3MB6S1JKpxgGQqi0= +github.com/decred/dcrd/wire v1.6.0 h1:YOGwPHk4nzGr6OIwUGb8crJYWDiVLpuMxfDBCCF7s/o= +github.com/decred/dcrd/wire v1.6.0/go.mod h1:XQ8Xv/pN/3xaDcb7sH8FBLS9cdgVctT7HpBKKGsIACk= +github.com/decred/go-socks v1.1.0 h1:dnENcc0KIqQo3HSXdgboXAHgqsCIutkqq6ntQjYtm2U= +github.com/decred/go-socks v1.1.0/go.mod h1:sDhHqkZH0X4JjSa02oYOGhcGHYp12FsY1jQ/meV8md0= +github.com/decred/slog v1.2.0 h1:soHAxV52B54Di3WtKLfPum9OFfWqwtf/ygf9njdfnPM= +github.com/decred/slog v1.2.0/go.mod h1:kVXlGnt6DHy2fV5OjSeuvCJ0OmlmTF6LFpEPMu/fOY0= +github.com/decred/vspd/client/v2 v2.0.0 h1:gaSF1Bm2/EvoAiSLxNR5fgStrObAO66xmanhedidYIM= +github.com/decred/vspd/client/v2 v2.0.0/go.mod h1:IDDviEe/6CuxxrW0PLOcg448enU3YmeElFHledYHw78= +github.com/decred/vspd/types/v2 v2.1.0 h1:cUVlmHPeLVsksPRnr2WHsmC2t1Skl6g1WH0HmpcPS7w= +github.com/decred/vspd/types/v2 v2.1.0/go.mod h1:2xnNqedkt9GuL+pK8uIzDxqYxFlwLRflYFJH64b76n0= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc= +github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= +github.com/jrick/bitset v1.0.0 h1:Ws0PXV3PwXqWK2n7Vz6idCdrV/9OrBXgHEJi27ZB9Dw= +github.com/jrick/bitset v1.0.0/go.mod h1:ZOYB5Uvkla7wIEY4FEssPVi3IQXa02arznRaYaAEPe4= +github.com/jrick/logrotate v1.0.0 h1:lQ1bL/n9mBNeIXoTUoYRlK4dHuNJVofX9oWqBtPnSzI= +github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= +github.com/jrick/wsrpc/v2 v2.3.5 h1:CwdycaR/df09iGkPMXs1FxqAHMCQbdAiTGoHfOrtuds= +github.com/jrick/wsrpc/v2 v2.3.5/go.mod h1:7oBeDM/xMF6Yqy4GDAjpppuOf1hm6lWsaG3EaMrm+aA= +github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= +github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= +go.etcd.io/bbolt v1.3.7 h1:j+zJOnnEjF/kyHlDDgGnVL/AIqIJPq8UoB2GSNfkUfQ= +go.etcd.io/bbolt v1.3.7/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw= +golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= +golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.7.0 h1:BEvjmm5fURWqcfbSKTdpkDXYBrUS1c0m8agp14W48vQ= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 h1:KpwkzHKEF7B9Zxg18WzOa7djJ+Ha5DzthMyZYQfEn2A= +google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1/go.mod h1:nKE/iIaLqn2bQwXBg8f1g2Ylh6r5MN5CmZvuzZCgsCU= +google.golang.org/grpc v1.54.0 h1:EhTqbhiYeixwWQtAEZAxmV9MGqcjEU2mFx52xCzNyag= +google.golang.org/grpc v1.54.0/go.mod h1:PUSEXI6iWghWaB6lXM4knEgpJNu2qUcKfDtNci3EC2g= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= +google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI= +lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= +matheusd.com/testctx v0.1.0 h1:MBpaNuqr23ugnkA59gz8Bd6BQIGkvZr7M4vYAc/Apzc= +matheusd.com/testctx v0.1.0/go.mod h1:u9la0YA1XIBcEpTU/aHJ9q4/L0VttkwhkG2m4lrj7Ls= diff --git a/dcrwtest/grpc.go b/dcrwtest/grpc.go new file mode 100644 index 0000000..5fc68e7 --- /dev/null +++ b/dcrwtest/grpc.go @@ -0,0 +1,202 @@ +// Copyright (c) 2023 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. +package dcrwtest + +import ( + "context" + "crypto/rand" + "crypto/x509" + "fmt" + "os" + "time" + + pb "decred.org/dcrwallet/v3/rpc/walletrpc" + "google.golang.org/grpc" +) + +// rpcSyncer syncs the wallet through the gRPC syncer that uses an underlying +// dcrd node in RPC mode. +type rpcSyncer struct { + c pb.WalletLoaderService_RpcSyncClient +} + +func (r *rpcSyncer) RecvSynced() (bool, error) { + msg, err := r.c.Recv() + if err != nil { + // All errors are final here. + return false, err + } + return msg.Synced, nil +} + +// spvSyncer syncs the wallet through the gRPC syncer that uses underlying +// nodes in SPV mode. +type spvSyncer struct { + c pb.WalletLoaderService_SpvSyncClient +} + +func (r *spvSyncer) RecvSynced() (bool, error) { + msg, err := r.c.Recv() + if err != nil { + // All errors are final here. + return false, err + } + return msg.Synced, nil +} + +// syncer matches both the RPC and SPV syncers. +type syncer interface { + RecvSynced() (bool, error) +} + +func tlsCertFromFile(fname string) (*x509.CertPool, []byte, error) { + b, err := os.ReadFile(fname) + if err != nil { + return nil, nil, err + } + cp := x509.NewCertPool() + if !cp.AppendCertsFromPEM(b) { + return nil, nil, fmt.Errorf("credentials: failed to append certificates") + } + + return cp, b, nil +} + +// waitNoError waits until the passed predicate returns nil or the timeout +// expires. +func waitNoError(pred func() error, timeout time.Duration) error { + const pollInterval = 20 * time.Millisecond + + exitTimer := time.After(timeout) + var err error + for { + select { + case <-time.After(pollInterval): + case <-exitTimer: + return fmt.Errorf("timeout waiting for no error predicate: %v", err) + } + + err = pred() + if err == nil { + return nil + } + } +} + +// GrpcCtl offers the gRPC control interface for a [Wallet]. +type GrpcCtl struct { + w *Wallet + cfg *config + grpcConn *grpc.ClientConn + loader pb.WalletLoaderServiceClient + pb.WalletServiceClient +} + +// runSync runs one of the gRPC syncers for the wallet. +func (w *GrpcCtl) runSync(ctx context.Context) error { +nextConn: + for ctx.Err() == nil { + var syncStream syncer + loader := pb.NewWalletLoaderServiceClient(w.grpcConn) + var err error + switch { + case w.cfg.rpcConnCfg != nil: + // Run the rpc syncer. + dcrd := w.cfg.rpcConnCfg + req := &pb.RpcSyncRequest{ + NetworkAddress: dcrd.Host, + Username: dcrd.User, + Password: []byte(dcrd.Pass), + Certificate: dcrd.Certificates, + DiscoverAccounts: w.cfg.discoverAccounts, + PrivatePassphrase: w.cfg.privatePass, + } + var res pb.WalletLoaderService_RpcSyncClient + res, err = loader.RpcSync(ctx, req) + syncStream = &rpcSyncer{c: res} + + case w.cfg.spv: + // Run the spv syncer. + req := &pb.SpvSyncRequest{ + SpvConnect: w.cfg.spvConnect, + DiscoverAccounts: w.cfg.discoverAccounts, + PrivatePassphrase: w.cfg.privatePass, + } + var res pb.WalletLoaderService_SpvSyncClient + res, err = loader.SpvSync(ctx, req) + syncStream = &spvSyncer{c: res} + + default: + return fmt.Errorf("no syncer configured") + } + + if err != nil { + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Second): + continue nextConn + } + } + + for { + synced, err := syncStream.RecvSynced() + if err != nil { + // In case of errors, wait a second and try + // running the syncer again. + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Second): + continue nextConn + } + } + + if synced { + // Close the initial sync channel. + select { + case <-w.w.syncDone: + default: + close(w.w.syncDone) + } + } + } + } + + return ctx.Err() +} + +// create attempts to create the wallet through the gRPC interface. +func (w *GrpcCtl) create(ctx context.Context) error { + if w.cfg.hdSeed == nil { + w.cfg.hdSeed = make([]byte, 32) + n, err := rand.Read(w.cfg.hdSeed) + if n != 32 || err != nil { + return fmt.Errorf("not enough entropy") + } + } + + reqCreate := &pb.CreateWalletRequest{ + Seed: w.cfg.hdSeed, + PrivatePassphrase: w.cfg.privatePass, + } + _, err := w.loader.CreateWallet(ctx, reqCreate) + return err +} + +// open attempts to open the wallet through the gRPC interface. +func (w *GrpcCtl) open(ctx context.Context) error { + _, err := w.loader.OpenWallet(ctx, &pb.OpenWalletRequest{}) + return err +} + +// exists attempts to determine whether the wallet exists through the gRPC +// interface. +func (w *GrpcCtl) exists(ctx context.Context) (bool, error) { + exists, err := w.loader.WalletExists(ctx, &pb.WalletExistsRequest{}) + if err != nil { + return false, fmt.Errorf("unable to query wallet's existence: %v", err) + } + return exists.Exists, nil +} diff --git a/dcrwtest/ipc.go b/dcrwtest/ipc.go new file mode 100644 index 0000000..20a98bc --- /dev/null +++ b/dcrwtest/ipc.go @@ -0,0 +1,119 @@ +// Copyright (c) 2023 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. +package dcrwtest + +import ( + "encoding/binary" + "fmt" + "io" + "os" +) + +// ipcPipePair holds both ends of an IPC pipe used to communicate with dcrd. +type ipcPipePair struct { + r *os.File + w *os.File + + // Whether to close the R and/or W ends. + closeR, closeW bool +} + +// close closes the required ends of the pipe and returns the first error. +func (p ipcPipePair) close() error { + var errR, errW error + if p.closeR { + errR = p.r.Close() + } + if p.closeW { + errW = p.w.Close() + } + if errR == nil { + return errW + } + return errR +} + +// newIPCPipePair creates a new IPC pipe pair. +func newIPCPipePair(closeR, closeW bool) (ipcPipePair, error) { + r, w, err := os.Pipe() + if err != nil { + return ipcPipePair{}, err + } + return ipcPipePair{r: r, w: w, closeR: closeR, closeW: closeW}, nil +} + +// pipeMessage is a generic interface for dcrd pipe messages. +type pipeMessage interface{} + +// boundJSONRPCListenAddrEvent is a pipeMessage that tracks the json RPC +// address of the underlying dcrwallet instance. +type boundJSONRPCListenAddrEvent string + +// boundGRPCListenAddrEvent is a pipeMessage that tracks the RPC address of the +// underlying dcrwallet instance. +type boundGRPCListenAddrEvent string + +// issuedClientCertEvent is a pipeMessage that indicates the client cert has +// been generated. +type issuedClientCertEvent []byte + +// nextIPCMessage returns the next dcrd IPC message read from the passed +// reading-end pipe. +// +// For unknown messages, this returns an empty pipeMessage instead of an error. +func nextIPCMessage(r io.Reader) (pipeMessage, error) { + var emptyMsg pipeMessage + const protocolVersion = 1 + + // Decode the header. + var bProto [1]byte + var bLenType [1]byte + var bType [255]byte + var bLenPay [4]byte + + // Enforce the protocol version. + if _, err := io.ReadFull(r, bProto[:]); err != nil { + return emptyMsg, fmt.Errorf("unable to read protocol: %v", err) + } + gotProtoVersion := bProto[0] + if gotProtoVersion != protocolVersion { + return emptyMsg, fmt.Errorf("protocol version mismatch: %d != %d", + gotProtoVersion, protocolVersion) + } + + // Decode rest of header. + if _, err := io.ReadFull(r, bLenType[:]); err != nil { + return emptyMsg, fmt.Errorf("unable to read type length: %v", err) + } + lenType := bLenType[0] + if _, err := io.ReadFull(r, bType[:lenType]); err != nil { + return emptyMsg, fmt.Errorf("unable to read type: %v", err) + } + if _, err := io.ReadFull(r, bLenPay[:]); err != nil { + return emptyMsg, fmt.Errorf("unable to read payload length: %v", err) + } + + // The existing IPC messages are small, so reading the entire message + // in an in-memory buffer is feasible today. + lenPay := binary.LittleEndian.Uint32(bLenPay[:]) + payload := make([]byte, lenPay) + if _, err := io.ReadFull(r, payload); err != nil { + return emptyMsg, fmt.Errorf("unable to read payload: %v", err) + } + + // Decode the payload based on the type. + typ := string(bType[:lenType]) + switch typ { + case "jsonrpclistener": + return boundJSONRPCListenAddrEvent(string(payload)), nil + case "grpclistener": + return boundGRPCListenAddrEvent(string(payload)), nil + case "issuedclientcertificate": + return issuedClientCertEvent(payload), nil + default: + // Other message types are unsupported but don't cause a read + // error. + return emptyMsg, nil + } +} diff --git a/dcrwtest/jsonrpc.go b/dcrwtest/jsonrpc.go new file mode 100644 index 0000000..80ff246 --- /dev/null +++ b/dcrwtest/jsonrpc.go @@ -0,0 +1,179 @@ +// Copyright (c) 2023 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. +package dcrwtest + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "sync" + "time" + + "decred.org/dcrwallet/v3/rpc/client/dcrwallet" + "decred.org/dcrwallet/v3/rpc/jsonrpc/types" + "github.com/decred/dcrd/dcrjson/v4" + "github.com/jrick/wsrpc/v2" +) + +// JsonRPCCtl offers the JSON-RPC control interface of the wallet. +type JsonRPCCtl struct { + // WS config. + host string + user string + pass string + disableTLS bool + certificates []byte + + // Current connection. + mtx sync.Mutex + c *dcrwallet.Client + connected chan struct{} + + w *Wallet +} + +// wc returns the current wallet client or a channel that is closed when the +// control interface has connected to the wallet. +func (jctl *JsonRPCCtl) wc() (*dcrwallet.Client, <-chan struct{}) { + jctl.mtx.Lock() + c, connected := jctl.c, jctl.connected + jctl.mtx.Unlock() + return c, connected +} + +// C waits until a connection to the wallet is made, and returns a JSON-RPC +// client to interface with the wallet. +func (jctl *JsonRPCCtl) C(ctx context.Context) (*dcrwallet.Client, error) { + for { + c, connected := jctl.wc() + if c != nil { + return c, ctx.Err() + } + + select { + case <-connected: + case <-ctx.Done(): + return nil, ctx.Err() + } + } +} + +// runSync runs the syncer through the JSON-RPC interface. +func (jctl *JsonRPCCtl) runSync(ctx context.Context) error { + var syncStatus types.SyncStatusResult + for ctx.Err() == nil { + // Wait until connected to the wallet. + c, err := jctl.C(ctx) + if err != nil { + return err + } + + // Fetch sync status. + err = c.Call(ctx, "syncstatus", &syncStatus) + wsrpcErr := new(wsrpc.Error) + var synced bool + switch { + case errors.As(err, &wsrpcErr) && wsrpcErr.Code == int64(dcrjson.ErrRPCClientNotConnected): + // Ignore this error as it means the wallet is not + // connected to the dcrd node yet. + case err != nil: + return err + default: + synced = syncStatus.Synced + } + + if synced { + select { + case <-jctl.w.syncDone: + default: + // time.Sleep(2 * time.Second) // FIXME remove after dcrwallet#2317 is closed + close(jctl.w.syncDone) + } + break + } + + select { + case <-ctx.Done(): + case <-time.After(10 * time.Millisecond): + } + } + + return ctx.Err() +} + +// run attempts to make a connection to the wallet through the JSON-RPC +// interface. +func (jctl *JsonRPCCtl) run(ctx context.Context) error { + const retryInterval = time.Second * 5 + + // Config. + var host string + if jctl.disableTLS { + host = "ws://" + } else { + host = "wss://" + } + host += jctl.host + "/ws" + + var opts []wsrpc.Option + if jctl.user != "" { + opts = append(opts, wsrpc.WithBasicAuth(jctl.user, jctl.pass)) + } + if jctl.certificates != nil { + tc := &tls.Config{RootCAs: x509.NewCertPool()} + if !tc.RootCAs.AppendCertsFromPEM(jctl.certificates) { + return fmt.Errorf("unparsable root certificate chain") + } + opts = append(opts, wsrpc.WithTLSConfig(tc)) + } + + log.Debugf("Attempting to connect to jsonRPC wallet at %v", host) + + for ctx.Err() == nil { + // Attempt connection. + cc, err := wsrpc.Dial(ctx, host, opts...) + if ctx.Err() != nil { + return ctx.Err() + } + if err != nil { + log.Warnf("Error connecting to wallet (%v). Retrying in %s", + err, retryInterval) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(retryInterval): + continue + } + } + + // Signal connection was made. + c := dcrwallet.NewClient(cc, jctl.w.cfg.chainParams) + jctl.mtx.Lock() + jctl.c = c + close(jctl.connected) + jctl.mtx.Unlock() + + // Wait until the connection is closed. + select { + case <-cc.Done(): + jctl.mtx.Lock() + jctl.c = nil + jctl.connected = make(chan struct{}) + jctl.mtx.Unlock() + + log.Infof("Disconnected from wallet. Retrying connection.") + case <-ctx.Done(): + select { + case <-cc.Done(): + default: + cc.Close() + } + return ctx.Err() + } + } + + return ctx.Err() +} diff --git a/dcrwtest/log.go b/dcrwtest/log.go new file mode 100644 index 0000000..8777463 --- /dev/null +++ b/dcrwtest/log.go @@ -0,0 +1,17 @@ +// Copyright (c) 2023 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. +package dcrwtest + +import "github.com/decred/slog" + +// log is a logger that is initialized with no output filters. This +// means the package will not perform any logging by default until the caller +// requests it. +// The default amount of logging is none. +var log = slog.Disabled + +// UseLogger uses a specified Logger to output package logging info. +func UseLogger(logger slog.Logger) { + log = logger +} diff --git a/dcrwtest/remotewallet.go b/dcrwtest/remotewallet.go new file mode 100644 index 0000000..59ffb26 --- /dev/null +++ b/dcrwtest/remotewallet.go @@ -0,0 +1,732 @@ +// Copyright (c) 2023 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. +package dcrwtest + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path" + "path/filepath" + "regexp" + "sync/atomic" + "time" + + pb "decred.org/dcrwallet/v3/rpc/walletrpc" + "github.com/decred/dcrd/chaincfg/v3" + "github.com/decred/dcrd/rpcclient/v8" + "github.com/decred/dcrd/wire" + "golang.org/x/sync/errgroup" + "google.golang.org/grpc" + "google.golang.org/grpc/backoff" + "google.golang.org/grpc/credentials" + "matheusd.com/testctx" +) + +type config struct { + dataDir string + dcrwalletPath string + chainParams *chaincfg.Params + debugLevel string + extraArgs []string + stdout io.Writer + stderr io.Writer + + hdSeed []byte + privatePass []byte + createUsingStdio bool + createWalletGrpc bool + openGrpcIfExists bool + waitInitialSync bool + pvtPassToStdin bool + + spv bool + spvConnect []string + rpcConnCfg *rpcclient.ConnConfig + syncUsingGrpc bool + syncUsingJrpc bool + discoverAccounts bool + syncUsingArgs bool + + noLegacyRPC bool + noGRPC bool + rpcUser string + rpcPass string +} + +func (cfg *config) tlsPaths() (tlsCertPath, tlsKeyPath string) { + tlsCertPath = path.Join(cfg.dataDir, "rpc.cert") + tlsKeyPath = path.Join(cfg.dataDir, "rpc.key") + return +} + +func (cfg *config) args() []string { + tlsCertPath, tlsKeyPath := cfg.tlsPaths() + args := []string{ + "--appdata=" + cfg.dataDir, + "--tlscurve=P-256", + "--rpccert=" + tlsCertPath, + "--rpckey=" + tlsKeyPath, + "--clientcafile=" + tlsCertPath, + "--rpclistenerevents", + } + if cfg.chainParams.Net == wire.TestNet3 { + args = append(args, "--testnet") + } else { + args = append(args, "--"+cfg.chainParams.Name) + } + if cfg.noLegacyRPC { + args = append(args, "--nolegacyrpc") + } else { + args = append(args, "--rpclisten=127.0.0.1:0") + args = append(args, "--username="+cfg.rpcUser) + args = append(args, "--password="+cfg.rpcPass) + } + if cfg.noGRPC { + args = append(args, "--nogrpc") + } else { + args = append(args, "--grpclisten=127.0.0.1:0") + } + if cfg.debugLevel != "" { + args = append(args, "--debuglevel="+cfg.debugLevel) + } + if cfg.syncUsingGrpc { + args = append(args, "--noinitialload") + } + if cfg.syncUsingArgs && cfg.rpcConnCfg != nil { + if cfg.rpcConnCfg.Certificates != nil { + args = append(args, "--cafile="+filepath.Join(cfg.dataDir, "dcrd-rpc.cert")) + } + if cfg.rpcConnCfg.DisableTLS { + args = append(args, "--noclienttls") + } + args = append(args, "--dcrdusername="+cfg.rpcConnCfg.User) + args = append(args, "--dcrdpassword="+cfg.rpcConnCfg.Pass) + args = append(args, "--rpcconnect="+cfg.rpcConnCfg.Host) + } + if cfg.syncUsingArgs && cfg.spv { + args = append(args, "--spv") + for _, spvConn := range cfg.spvConnect { + args = append(args, "--spvconnect="+spvConn) + } + } + if len(cfg.extraArgs) > 0 { + args = append(args, cfg.extraArgs...) + } + + return args +} + +// Opt is a config option for a new wallet. +type Opt func(*config) + +// WithCreateUsingStdio attempts to create the wallet using the stdin/stdout +// method (passing passphrase, seed, and so on through stdin). +func WithCreateUsingStdio() Opt { + return func(cfg *config) { + cfg.createWalletGrpc = false + cfg.createUsingStdio = true + cfg.pvtPassToStdin = true + } +} + +// WithCreateUsingGRPC attempts to create the wallet using the gRPC interface. +func WithCreateUsingGRPC() Opt { + return func(cfg *config) { + cfg.createWalletGrpc = true + cfg.createUsingStdio = false + cfg.pvtPassToStdin = false + } +} + +// WithSyncUsingJsonRPC configures the wallet to sync using the standard +// process arguments. +func WithSyncUsingJsonRPC() Opt { + return func(cfg *config) { + cfg.syncUsingArgs = true + cfg.syncUsingJrpc = true + cfg.syncUsingGrpc = false + cfg.openGrpcIfExists = false + } +} + +// WithSyncUsingGRPC configures the wallet to sync using a gRPC syncer. +func WithSyncUsingGRPC() Opt { + return func(cfg *config) { + cfg.syncUsingArgs = false + cfg.syncUsingJrpc = false + cfg.syncUsingGrpc = true + cfg.openGrpcIfExists = true + } +} + +// WithRPCSync configures the wallet to sync to the network using the specified +// dcrd node in RPC mode. +func WithRPCSync(rpcConnCfg rpcclient.ConnConfig) Opt { + return func(cfg *config) { + cfg.rpcConnCfg = &rpcConnCfg + } +} + +// WithSPVSync configures the wallet to sync to the network in SPV mode, +// optionally using a list of nodes to connect to. +func WithSPVSync(spvConnect ...string) Opt { + return func(cfg *config) { + cfg.spv = true + cfg.spvConnect = spvConnect + } +} + +// WithExposeCmdOutput redirects the wallet's stdout and stderr streams to the +// current process' stdout and stderr. +func WithExposeCmdOutput() Opt { + return func(cfg *config) { + cfg.stdout = os.Stdout + cfg.stderr = os.Stderr + } +} + +// WithDebugLevel specifies the debug level to use when running the wallet. +func WithDebugLevel(level string) Opt { + return func(cfg *config) { + cfg.debugLevel = level + } +} + +// WithRestoreFromWallet generates a wallet with the same seed as a prior +// wallet. +func WithRestoreFromWallet(w *Wallet) Opt { + return func(cfg *config) { + cfg.hdSeed = w.cfg.hdSeed + } +} + +// WithHDSeed creates a wallet with the specified (raw, binary) seed. +func WithHDSeed(seed []byte) Opt { + return func(cfg *config) { + cfg.hdSeed = seed + } +} + +// WithRestartWallet creates a wallet that will restart a previously running +// wallet. The prior wallet must have finished running before the new wallet +// runs. +func WithRestartWallet(w *Wallet) Opt { + return func(cfg *config) { + cfg.dataDir = w.cfg.dataDir + cfg.createUsingStdio = false + cfg.createWalletGrpc = false + } +} + +// WithNoWaitInitialSync disables the wait for the initial sync to be completed +// before marking the wallet as ready to be used. +func WithNoWaitInitialSync() Opt { + return func(cfg *config) { + cfg.waitInitialSync = false + } +} + +// WithExtraArgs passes additional args when running the wallet process. +func WithExtraArgs(args ...string) Opt { + return func(cfg *config) { + cfg.extraArgs = args + } +} + +// Wallet is a dcrwallet instance. Method [Run] runs the wallet. +type Wallet struct { + cfg *config + + runState atomic.Int32 + running chan struct{} + runDone chan struct{} + syncDone chan struct{} + gctl atomic.Pointer[GrpcCtl] + jctl atomic.Pointer[JsonRPCCtl] +} + +// New creates a dcrwallet instance. The instance will run when its +// [Wallet.Run] method is called. [dataDir] may point to a dir that is empty, +// in which case a new wallet will be created (if one of the WithCreate options +// are specified). +// +// The default config for a wallet is to both create (if needed) and sync the +// wallet using its gRPC interface. +func New(dataDir string, chainParams *chaincfg.Params, opts ...Opt) (*Wallet, error) { + // Setup and sanity check config. + cfg := &config{ + dataDir: dataDir, + chainParams: chainParams, + + waitInitialSync: true, + privatePass: []byte("privatepwd"), + discoverAccounts: true, + rpcUser: "user", + rpcPass: "pass", + } + WithCreateUsingGRPC()(cfg) + WithSyncUsingGRPC()(cfg) + for i := range opts { + opts[i](cfg) + } + + if cfg.spv && cfg.rpcConnCfg != nil { + return nil, errors.New("only one of rpcConnCfg or spv should be specified") + } + + // Create the dcrwallet binary used for tests if not created yet. + if cfg.dcrwalletPath == "" { + var err error + cfg.dcrwalletPath, err = globalPathToDcrw() + if err != nil { + return nil, err + } + } + + w := &Wallet{ + cfg: cfg, + running: make(chan struct{}), + syncDone: make(chan struct{}), + runDone: make(chan struct{}), + } + return w, nil +} + +// ChainParams returns the chain parameters the wallet has been configured with. +func (w *Wallet) ChainParams() *chaincfg.Params { + return w.cfg.chainParams +} + +// GrpcCtl returns a gRPC control interface for the wallet. This will be nil +// if the wallet has not been configured to run gRPC or if the gRPC address +// has not been determined yet. +func (w *Wallet) GrpcCtl() *GrpcCtl { + return w.gctl.Load() +} + +// JsonRPCCtl returns JSON-RPC control interface for the wallet. This will be +// nil if the wallet has not been configured to run JSON-RPC or if the JSON-RPC +// address has not been determined yet. +func (w *Wallet) JsonRPCCtl() *JsonRPCCtl { + return w.jctl.Load() +} + +// Running is closed when the wallet process has been started and all initial +// setup with has finished (including creating the wallet, determining and +// starting gRPC and JSON-RPC control interfaces and performing the initial +// wallet sync, all according to the options provided in New()). +func (w *Wallet) Running() <-chan struct{} { + return w.running +} + +// RunDone is closed when the wallet has finished running. +func (w *Wallet) RunDone() <-chan struct{} { + return w.runDone +} + +// Synced is closed when the initial sync has completed. This is only closed +// if the wallet was configured to both perform and wait for the initial sync. +func (w *Wallet) Synced() <-chan struct{} { + return w.syncDone +} + +// createUsingStdio attempts to create the wallet using the stdio method. +func (w *Wallet) createUsingStdio(ctx context.Context) error { + cfg := w.cfg + args := w.cfg.args() + createArgs := append([]string{"--create"}, args...) + cmd := exec.CommandContext(ctx, cfg.dcrwalletPath, createArgs...) + + log.Debugf("Creating wallet using stdio with args %v", createArgs) + + isRestore := cfg.hdSeed != nil + responses := string(cfg.privatePass) + "\n" + string(cfg.privatePass) + "\n" + responses += "no\n" // Public Passphrase? + if !isRestore { + responses += "no\nOK\n" + } else { + responses += "yes\n" + hex.EncodeToString(cfg.hdSeed) + "\n\n" + } + cmd.Stdin = bytes.NewBuffer([]byte(responses)) + + out, err := cmd.CombinedOutput() + if err != nil { + log.Tracef("Output: %s", out) + return fmt.Errorf("unable to create wallet: %v", err) + } + + // Extract seed from output. + matches := regexp.MustCompile(`(?m)^Hex: ([0-9a-f]{64})$`).FindStringSubmatch(string(out)) + if len(matches) != 2 { + return fmt.Errorf("unable to extract seed from wallet creation output") + } + + cfg.hdSeed, err = hex.DecodeString(matches[1]) + if err != nil { + return fmt.Errorf("extracted seed is not an hex: %v", err) + } + if cfg.chainParams.Net != wire.MainNet { + log.Tracef("Wallet seed: %x", cfg.hdSeed) + } + + return nil +} + +// Run the wallet instance according to the configured options. The wallet runs +// until it either errors or the passed context is closed. +func (w *Wallet) Run(rctx context.Context) error { + if !w.runState.CompareAndSwap(0, 1) { + return errors.New("cannot run wallet more than once") + } + defer close(w.runDone) + + // Create wallet using the stdin prompt. + if w.cfg.createUsingStdio { + if err := w.createUsingStdio(rctx); err != nil { + return err + } + } + + // Context that will be closed once Run() ends to shutdown everything. + ctx, cancel := context.WithCancel(rctx) + g, ctx := errgroup.WithContext(ctx) + defer g.Wait() // Ensure early returns wait until everything is cleaned up. + defer cancel() + + // Write dcrd ca file to data dir. + if w.cfg.rpcConnCfg != nil && w.cfg.rpcConnCfg.Certificates != nil { + fname := filepath.Join(w.cfg.dataDir, "dcrd-rpc.cert") + err := os.WriteFile(fname, w.cfg.rpcConnCfg.Certificates, 0o644) + if err != nil { + return err + } + } + + // Prepare IPC. + pipeTX, err := newIPCPipePair(true, false) + if err != nil { + return fmt.Errorf("unable to create pipe for dcrd IPC: %v", err) + } + g.Go(func() error { + <-ctx.Done() + return pipeTX.close() + }) + pipeRX, err := newIPCPipePair(false, true) + if err != nil { + return fmt.Errorf("unable to create pipe for dcrd IPC: %v", err) + } + g.Go(func() error { + // Closing pipeRX causes the wallet to be shutdown. + <-ctx.Done() + return pipeRX.close() + }) + + // Setup the args to run the underlying dcrwallet. + args := w.cfg.args() + args = appendOSWalletArgs(&pipeTX, &pipeRX, args) + + // The wallet may need to be unlocked for address discovery to happen, + // so pass the private passphrase to stdin. + var stdin io.Reader + if w.cfg.pvtPassToStdin { + stdin = bytes.NewBufferString(string(w.cfg.privatePass) + "\n") + } + + // Run dcrwallet. + log.Debugf("Running %s with args %v", w.cfg.dcrwalletPath, args) + cmd := exec.Command(w.cfg.dcrwalletPath, args...) + setOSWalletCmdOptions(&pipeTX, &pipeRX, cmd) + cmd.Stdin = stdin + cmd.Stdout = w.cfg.stdout + cmd.Stderr = w.cfg.stderr + err = cmd.Start() + if err != nil { + return fmt.Errorf("unable to start dcrwallet: %v", err) + } + g.Go(cmd.Wait) + + // Read the subsystem addresses. + gotGrpcAddr, gotJrpcAddr := make(chan struct{}), make(chan struct{}) + var grpcAddr, jrpcAddr string + g.Go(func() error { + for { + msg, err := nextIPCMessage(pipeTX.r) + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + log.Debugf("pipeTX reading errored: %v", err) + return err + } + switch msg := msg.(type) { + case boundJSONRPCListenAddrEvent: + if jrpcAddr == "" { + jrpcAddr = string(msg) + log.Debugf("Determined jsonRPC address %s", + jrpcAddr) + close(gotJrpcAddr) + } + case boundGRPCListenAddrEvent: + if grpcAddr == "" { + grpcAddr = string(msg) + log.Debugf("Determined gRPC address %s", + grpcAddr) + close(gotGrpcAddr) + } + } + } + }) + + // Read the wallet TLS cert and client cert and key files. + tlsCertPath, tlsKeyPath := w.cfg.tlsPaths() + var caCert *x509.CertPool + var clientCert tls.Certificate + var rawCaCert []byte + err = waitNoError(func() error { + var err error + caCert, rawCaCert, err = tlsCertFromFile(tlsCertPath) + if err != nil { + return fmt.Errorf("unable to load wallet ca cert: %v", err) + } + + clientCert, err = tls.LoadX509KeyPair(tlsCertPath, tlsKeyPath) + if err != nil { + return fmt.Errorf("unable to load wallet cert and key files: %v", err) + } + + return nil + }, time.Second*30) + if err != nil { + return fmt.Errorf("unable to read client cert files: %v", err) + } + + hasGrpc, hasJrpc := !w.cfg.noGRPC, !w.cfg.noLegacyRPC + syncing := false + var grpcConn *grpc.ClientConn + if hasGrpc { + // Wait until the gRPC address is read via IPC. + select { + case <-gotGrpcAddr: + case <-time.After(time.Second * 30): + return errors.New("timeout waiting for gRPC addr") + } + + // Setup the TLS config and credentials. + tlsCfg := &tls.Config{ + ServerName: "localhost", + RootCAs: caCert, + Certificates: []tls.Certificate{clientCert}, + } + creds := credentials.NewTLS(tlsCfg) + + grpcCfg := []grpc.DialOption{ + grpc.WithBlock(), + grpc.WithTransportCredentials(creds), + grpc.WithConnectParams(grpc.ConnectParams{ + Backoff: backoff.Config{ + BaseDelay: time.Millisecond * 20, + Multiplier: 1, + Jitter: 0.2, + MaxDelay: time.Millisecond * 20, + }, + MinConnectTimeout: time.Millisecond * 20, + }), + } + ctxb := context.Background() + dialCtx, cancel := context.WithTimeout(ctxb, time.Second*30) + defer cancel() + grpcConn, err = grpc.DialContext(dialCtx, grpcAddr, grpcCfg...) + if err != nil { + return fmt.Errorf("unable to dial gRPC: %v", err) + } + g.Go(func() error { + <-ctx.Done() + return grpcConn.Close() + }) + + loader := pb.NewWalletLoaderServiceClient(grpcConn) + gctl := &GrpcCtl{ + w: w, + cfg: w.cfg, + loader: loader, + grpcConn: grpcConn, + + WalletServiceClient: pb.NewWalletServiceClient(grpcConn), + } + + exists := false + if w.cfg.openGrpcIfExists || w.cfg.createWalletGrpc { + exists, err = gctl.exists(ctx) + if err != nil { + return err + } + } + + // Create wallet if needed and instructed to. + opened := false + if exists && w.cfg.openGrpcIfExists { + err := gctl.open(ctx) + if err != nil { + return fmt.Errorf("unable to open wallet "+ + "using gRPC: %v", err) + } + opened = true + } else if !exists && w.cfg.createWalletGrpc { + err := gctl.create(ctx) + if err != nil { + return fmt.Errorf("unable to create wallet "+ + "using gRPC: %v", err) + } + opened = true + } + + // Sync via gRPC if instructed to. + if opened && w.cfg.syncUsingGrpc && (w.cfg.spv || w.cfg.rpcConnCfg != nil) { + syncing = true + g.Go(func() error { return gctl.runSync(ctx) }) + } + + w.gctl.Store(gctl) + } + + if hasJrpc { + // Wait until the jsonRPC address is read via IPC. + select { + case <-gotJrpcAddr: + case <-time.After(time.Second * 30): + return errors.New("timeout waiting for jsonRPC addr") + } + + // Setup and run the JSON-RPC control interface. + jctl := &JsonRPCCtl{ + host: jrpcAddr, + user: w.cfg.rpcUser, + pass: w.cfg.rpcPass, + certificates: rawCaCert, + w: w, + + connected: make(chan struct{}), + } + g.Go(func() error { return jctl.run(ctx) }) + + // Sync via JSON-RPC if instructed to. + if w.cfg.syncUsingJrpc && (w.cfg.spv || w.cfg.rpcConnCfg != nil) { + syncing = true + g.Go(func() error { return jctl.runSync(ctx) }) + } + + w.jctl.Store(jctl) + } + + // Wait until the initial wallet sync is done if instructed to. + if syncing && w.cfg.waitInitialSync { + select { + case <-ctx.Done(): + return g.Wait() + case <-w.syncDone: + } + } + + // Wallet setup is done, signal clients. + close(w.running) + + // Run until the wallet fails or the context is closed. + return g.Wait() +} + +// TestIntf is the interface for a test object. +type TestIntf interface { + Cleanup(func()) + Failed() bool + Fatalf(string, ...any) + Logf(string, ...any) + Fail() +} + +// walletCount tracks the total number of wallets created by this package. +var walletCount atomic.Uint32 + +// RunForTest creates and runs a wallet with the passed config. If the test +// succeeds, then the wallet's data dir is cleaned up after the test completes. +// Otherwise, the dir is kept to ease debugging. +// +// When this function returns the wallet is ready to be used according to the +// passed options. +// +// The DCRWTEST_KEEP_WALLET_DIR environment variable can be set to 1 to +// forcibly prevent the wallet dir from being removed. +func RunForTest(ctx context.Context, t TestIntf, params *chaincfg.Params, opts ...Opt) *Wallet { + // Create temp wallet dir. + walletNum := walletCount.Add(1) + dataDir, err := os.MkdirTemp("", fmt.Sprintf("dcrw-%03d-*", walletNum)) + if err != nil { + t.Fatalf("Unable to create dir: %v", err) + } + + w, err := New(dataDir, params, opts...) + if err != nil { + t.Fatalf("Unable to create wallet: %v", err) + } + + // Cleanup wallet dir at the end of the test. + keepDir := os.Getenv("DCRWTEST_KEEP_WALLET_DIR") == "1" + t.Cleanup(func() { + if t.Failed() || keepDir { + t.Logf("Wallet %d dir: %v", walletNum, w.cfg.dataDir) + return + } + + _ = os.RemoveAll(dataDir) + }) + + // Run the wallet. + runErr := make(chan error, 1) + ctx, cancel := context.WithCancel(ctx) + go func() { runErr <- w.Run(ctx) }() + + select { + case err := <-runErr: + cancel() + t.Fatalf("Run() errored: %v", err) + case <-w.Running(): + // Wallet is running. Stop it after the test completes. + t.Cleanup(func() { + cancel() + var err error + select { + case err = <-runErr: + case <-time.After(30 * time.Second): + err = fmt.Errorf("timeout waiting for Run() to complete") + } + + if err != nil && !errors.Is(err, context.Canceled) { + t.Logf("Wallet %d Run() errored: %v", walletNum, err) + if !t.Failed() { + t.Fail() + } + } + }) + } + + return w +} + +// WaitTestWalletDone waits until a test wallet is done or times out if the +// wallet takes too long to stop. +func WaitTestWalletDone(t TestIntf, w *Wallet) { + ctx := testctx.WithTimeout(t, 30*time.Second) + select { + case <-ctx.Done(): + t.Fatalf("timeout waiting for wallet to be done") + case <-w.RunDone(): + } +} diff --git a/dcrwtest/remotewallet_common.go b/dcrwtest/remotewallet_common.go new file mode 100644 index 0000000..5a5208b --- /dev/null +++ b/dcrwtest/remotewallet_common.go @@ -0,0 +1,27 @@ +//go:build !windows +// +build !windows + +// Copyright (c) 2023 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. +package dcrwtest + +import ( + "os" + "os/exec" +) + +// setOSWalletCmdOptions sets platform-specific options needed to run dcrwallet. +func setOSWalletCmdOptions(pipeTX, pipeRX *ipcPipePair, cmd *exec.Cmd) { + cmd.ExtraFiles = []*os.File{ + pipeTX.w, + pipeRX.r, + } +} + +// appendOSWalletArgs appends platform-specific arguments needed to run dcrwallet. +func appendOSWalletArgs(pipeTX, pipeRX *ipcPipePair, args []string) []string { + args = append(args, "--pipetx=3") + args = append(args, "--piperx=4") + return args +} diff --git a/dcrwtest/remotewallet_test.go b/dcrwtest/remotewallet_test.go new file mode 100644 index 0000000..d79f45e --- /dev/null +++ b/dcrwtest/remotewallet_test.go @@ -0,0 +1,233 @@ +// Copyright (c) 2023 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package dcrwtest + +import ( + "bytes" + "errors" + "os" + "runtime/pprof" + "testing" + "time" + + "decred.org/dcrwallet/v3/rpc/client/dcrwallet" + "github.com/decred/dcrd/chaincfg/v3" + "github.com/decred/slog" + "golang.org/x/net/context" + "matheusd.com/testctx" +) + +// TestMain cleans up the built dcrwallet instance for tests. +func TestMain(m *testing.M) { + res := m.Run() + if res == 0 { + _ = CleanBuiltDcrwallet() + } + os.Exit(res) +} + +// loggerWriter is an slog backend that writes to a test output. +// +//nolint:unused +type loggerWriter struct { + l testing.TB +} + +//nolint:unused +func (lw loggerWriter) Write(b []byte) (int, error) { + bt := bytes.TrimRight(b, "\r\n") + lw.l.Logf(string(bt)) + return len(b), nil +} + +// setTestLogger sets the logger to log into the test. Cannot be used in +// parallel tests. +// +//nolint:unused +func setTestLogger(t testing.TB) { + // Add logging to ease debugging this test. + lw := loggerWriter{l: t} + bknd := slog.NewBackend(lw) + logger := bknd.Logger("TEST") + logger.SetLevel(slog.LevelTrace) + UseLogger(logger) + t.Cleanup(func() { + UseLogger(slog.Disabled) + }) +} + +func jrpcClient(t *testing.T, w *Wallet) *dcrwallet.Client { + t.Helper() + ctx := testctx.WithTimeout(t, 30*time.Second) + jctl := w.JsonRPCCtl() + c, err := jctl.C(ctx) + if err != nil { + t.Fatal("timeout waiting for JSON-RPC client") + } + return c +} + +// TestRunReturnsCleanly tests that the Run() method runs and cleanly terminates +// the wallet once canceled. +func TestRunReturnsCleanly(t *testing.T) { + // setTestLogger(t) + + // Keep track of how many goroutines are running before the test + // happens. + beforeCount := pprof.Lookup("goroutine").Count() + + dir, err := os.MkdirTemp("", "dcrw-run-test") + if err != nil { + t.Fatal(err) + } + w, err := New(dir, chaincfg.SimNetParams(), + // WithExposeCmdOutput(), + WithDebugLevel("debug"), + ) + if err != nil { + t.Fatal(err) + } + + // Run wallet. + ctx, cancel := context.WithCancel(context.Background()) + errChan := make(chan error, 1) + go func() { errChan <- w.Run(ctx) }() + + // Wait until it's completely running. + select { + case err := <-errChan: + t.Fatal(err) + case <-w.Running(): + case <-time.After(time.Minute): + t.Fatal("Timeout waiting for wallet to run") + } + + // Stop the wallet. + cancel() + select { + case err := <-errChan: + if !errors.Is(err, context.Canceled) { + t.Fatalf("Unexpected error: got %v, want %v", err, context.Canceled) + } + case <-time.After(30 * time.Second): + t.Fatal("Timeout waiting for wallet to finish running") + } + + // RunDone() should be closed. + select { + case <-w.RunDone(): + default: + t.Fatal("RunDone() was not closed before Run() returned") + } + + // There should be only 2 goroutines live. + time.Sleep(time.Millisecond) + prof := pprof.Lookup("goroutine") + afterCount := prof.Count() + if beforeCount != afterCount { + prof.WriteTo(os.Stderr, 1) + t.Fatalf("Unexpected nb of active goroutines: got %d, want %d", + afterCount, beforeCount) + } + + // Remove the wallet dir. + err = os.RemoveAll(dir) + if err != nil { + t.Fatal(err) + } +} + +// TestCreateWithStdio tests that the wallet is created when using the stdio +// streams for passing information. +func TestCreateWithStdio(t *testing.T) { + // setTestLogger(t) + opts := []Opt{ + WithCreateUsingStdio(), + WithSyncUsingJsonRPC(), + WithDebugLevel("trace"), + } + w := RunForTest(testctx.New(t), t, chaincfg.SimNetParams(), opts...) + _, err := jrpcClient(t, w).WalletInfo(testctx.New(t)) + if err != nil { + t.Fatal(err) + } +} + +// TestCreateWithGRPC tests that the wallet is created when using the gRPC +// interface for passing information. +func TestCreateWithGRPC(t *testing.T) { + // setTestLogger(t) + opts := []Opt{ + WithCreateUsingGRPC(), + WithDebugLevel("trace"), + } + w := RunForTest(testctx.New(t), t, chaincfg.SimNetParams(), opts...) + _, err := jrpcClient(t, w).WalletInfo(testctx.New(t)) + if err != nil { + t.Fatal(err) + } +} + +// TestRestoreWallet tests that creating and then restoring the wallet works. +func TestRestoreWallet(t *testing.T) { + // setTestLogger(t) + opts := []Opt{ + WithDebugLevel("trace"), + } + + // Create first wallet. + ctx, cancel := context.WithCancel(testctx.New(t)) + w1 := RunForTest(ctx, t, chaincfg.SimNetParams(), opts...) + addr1, err := jrpcClient(t, w1).GetNewAddress(testctx.New(t), "default") + if err != nil { + t.Fatal(err) + } + cancel() + WaitTestWalletDone(t, w1) + + // Restore using second wallet. The first address should be the same. + opts = append(opts, WithRestoreFromWallet(w1)) + w2 := RunForTest(testctx.New(t), t, chaincfg.SimNetParams(), opts...) + addr2, err := jrpcClient(t, w2).GetNewAddress(testctx.New(t), "default") + if err != nil { + t.Fatal(err) + } + + if addr1.String() != addr2.String() { + t.Fatalf("Restore generated unexpected addr: got %v, want %v", + addr2, addr1) + } +} + +// TestRestartWallet tests that creating, stopping then restarting the wallet +// works. +func TestRestartWallet(t *testing.T) { + // setTestLogger(t) + opts := []Opt{ + WithDebugLevel("trace"), + } + + // Create wallet. + ctx, cancel := context.WithCancel(testctx.New(t)) + w1 := RunForTest(ctx, t, chaincfg.SimNetParams(), opts...) + addr, err := jrpcClient(t, w1).GetNewAddress(testctx.New(t), "default") + if err != nil { + t.Fatal(err) + } + cancel() + WaitTestWalletDone(t, w1) + + // Restart wallet. + opts = append(opts, WithRestartWallet(w1)) + w2 := RunForTest(testctx.New(t), t, chaincfg.SimNetParams(), opts...) + valid, err := jrpcClient(t, w2).ValidateAddress(testctx.New(t), addr) + if err != nil { + t.Fatal(err) + } + + if !valid.IsMine { + t.Fatal("test address is not owned by wallet") + } +} diff --git a/dcrwtest/remotewallet_windows.go b/dcrwtest/remotewallet_windows.go new file mode 100644 index 0000000..fe4a1cd --- /dev/null +++ b/dcrwtest/remotewallet_windows.go @@ -0,0 +1,30 @@ +//go:build windows +// +build windows + +// Copyright (c) 2023 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. +package dcrwtest + +import ( + "fmt" + "os/exec" + "syscall" +) + +// setOSWalletCmdOptions sets platform-specific options needed to run dcrwallet. +func setOSWalletCmdOptions(pipeTX, pipeRX *ipcPipePair, cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + AdditionalInheritedHandles: []syscall.Handle{ + syscall.Handle(pipeTX.w.Fd()), + syscall.Handle(pipeRX.r.Fd()), + }, + } +} + +// appendOSWalletArgs appends platform-specific arguments needed to run dcrwallet. +func appendOSWalletArgs(pipeTX, pipeRX *ipcPipePair, args []string) []string { + args = append(args, fmt.Sprintf("--pipetx=%d", pipeTX.w.Fd())) + args = append(args, fmt.Sprintf("--piperx=%d", pipeRX.r.Fd())) + return args +} diff --git a/dcrwtest/require.go b/dcrwtest/require.go new file mode 100644 index 0000000..571acec --- /dev/null +++ b/dcrwtest/require.go @@ -0,0 +1,13 @@ +//go:build require +// +build require + +// This file exists to prevent go mod tidy from removing requires on tools. +// It is excluded from the build as it is not permitted to import main packages. + +package dcrwtest + +import ( + // NOTE: This MUST have the same value as the dcrwMainPkg var defined + // in builder.go. + _ "decred.org/dcrwallet/v3" +)