├── types ├── go.mod ├── types_test.go ├── errors_test.go ├── errors.go └── types.go ├── docs ├── img │ └── stakey.png ├── release-notes │ ├── release-notes.1.1.1.md │ ├── release-notes.1.2.1.md │ ├── release-notes.1.3.1.md │ ├── release-notes.1.3.2.md │ ├── release-notes.1.0.0.md │ ├── release-notes.1.2.0.md │ ├── release-notes.1.3.0.md │ └── release-notes.1.4.0.md ├── two-way-accountability.md └── listing.md ├── internal ├── webapi │ ├── public │ │ ├── images │ │ │ ├── favicon │ │ │ │ ├── favicon.ico │ │ │ │ ├── favicon-16x16.png │ │ │ │ ├── favicon-32x32.png │ │ │ │ ├── mstile-70x70.png │ │ │ │ ├── ic_launcher_hdpi.png │ │ │ │ ├── ic_launcher_mdpi.png │ │ │ │ ├── mstile-144x144.png │ │ │ │ ├── mstile-150x150.png │ │ │ │ ├── mstile-310x150.png │ │ │ │ ├── mstile-310x310.png │ │ │ │ ├── ic_launcher_xhdpi.png │ │ │ │ ├── ic_launcher_xxhdpi.png │ │ │ │ ├── ic_launcher_xxxhdpi.png │ │ │ │ ├── apple-touch-icon-57x57.png │ │ │ │ ├── apple-touch-icon-60x60.png │ │ │ │ ├── apple-touch-icon-72x72.png │ │ │ │ ├── apple-touch-icon-76x76.png │ │ │ │ ├── apple-touch-icon-114x114.png │ │ │ │ ├── apple-touch-icon-120x120.png │ │ │ │ ├── apple-touch-icon-144x144.png │ │ │ │ ├── apple-touch-icon-152x152.png │ │ │ │ ├── apple-touch-icon-180x180.png │ │ │ │ ├── browserconfig.xml │ │ │ │ └── manifest.json │ │ │ ├── success-icon.svg │ │ │ └── error-icon.svg │ │ └── css │ │ │ ├── fonts │ │ │ ├── SourceSansPro-It │ │ │ │ ├── SourceSansPro-It.eot │ │ │ │ ├── SourceSansPro-It.ttf │ │ │ │ ├── SourceSansPro-It.ttf.woff │ │ │ │ └── SourceSansPro-It.ttf.woff2 │ │ │ ├── SourceCodePro-Regular │ │ │ │ ├── SourceCodePro-Regular.eot │ │ │ │ ├── SourceCodePro-Regular.ttf │ │ │ │ ├── SourceCodePro-Regular.ttf.woff │ │ │ │ └── SourceCodePro-Regular.ttf.woff2 │ │ │ ├── SourceSansPro-Regular │ │ │ │ ├── SourceSansPro-Regular.eot │ │ │ │ ├── SourceSansPro-Regular.ttf │ │ │ │ ├── SourceSansPro-Regular.ttf.woff │ │ │ │ └── SourceSansPro-Regular.ttf.woff2 │ │ │ ├── SourceSansPro-Semibold │ │ │ │ ├── SourceSansPro-Semibold.eot │ │ │ │ ├── SourceSansPro-Semibold.ttf │ │ │ │ ├── SourceSansPro-Semibold.ttf.woff │ │ │ │ └── SourceSansPro-Semibold.ttf.woff2 │ │ │ └── SourceSansPro-SemiboldIt │ │ │ │ ├── SourceSansPro-SemiboldIt.eot │ │ │ │ ├── SourceSansPro-SemiboldIt.ttf │ │ │ │ ├── SourceSansPro-SemiboldIt.ttf.woff │ │ │ │ └── SourceSansPro-SemiboldIt.ttf.woff2 │ │ │ └── fonts.css │ ├── homepage.go │ ├── templates │ │ ├── login.html │ │ ├── footer.html │ │ ├── vsp-stats.html │ │ ├── homepage.html │ │ └── header.html │ ├── vspinfo.go │ ├── formatting_test.go │ ├── middleware_test.go │ ├── ticketstatus.go │ ├── formatting.go │ ├── addressgenerator.go │ ├── binding_test.go │ ├── recovery.go │ ├── cache.go │ ├── setaltsignaddr.go │ ├── helpers_test.go │ └── helpers.go ├── signal │ ├── signal_syscall.go │ └── signal.go ├── config │ ├── flags.go │ └── network.go ├── version │ └── version.go └── vspd │ ├── vspd.go │ └── databaseintegrity.go ├── .gitignore ├── rpc ├── semver.go ├── notifs.go └── client.go ├── LICENSE ├── .github └── workflows │ └── go.yml ├── cmd ├── v3tool │ ├── README.md │ └── dcrwallet.go ├── vote-validator │ ├── README.md │ ├── results.go │ └── dcrdata.go ├── vspd │ ├── log.go │ └── main.go └── vspadmin │ └── README.md ├── client ├── go.mod ├── go.sum └── client_test.go ├── run_tests.sh ├── .golangci.yml ├── database ├── upgrade_v4.go ├── encoding.go ├── votechange_test.go ├── upgrade_v2.go ├── encoding_test.go ├── upgrade_v5.go ├── upgrade_v3.go ├── upgrades.go ├── altsignaddr_test.go ├── feexpub_test.go ├── votechange.go ├── altsignaddr.go ├── feexpub.go └── database_test.go ├── go.mod ├── README.md └── harness.sh /types/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/decred/vspd/types/v3 2 | 3 | go 1.19 4 | -------------------------------------------------------------------------------- /docs/img/stakey.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/docs/img/stakey.png -------------------------------------------------------------------------------- /internal/webapi/public/images/favicon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/images/favicon/favicon.ico -------------------------------------------------------------------------------- /internal/webapi/public/images/favicon/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/images/favicon/favicon-16x16.png -------------------------------------------------------------------------------- /internal/webapi/public/images/favicon/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/images/favicon/favicon-32x32.png -------------------------------------------------------------------------------- /internal/webapi/public/images/favicon/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/images/favicon/mstile-70x70.png -------------------------------------------------------------------------------- /internal/webapi/public/images/favicon/ic_launcher_hdpi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/images/favicon/ic_launcher_hdpi.png -------------------------------------------------------------------------------- /internal/webapi/public/images/favicon/ic_launcher_mdpi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/images/favicon/ic_launcher_mdpi.png -------------------------------------------------------------------------------- /internal/webapi/public/images/favicon/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/images/favicon/mstile-144x144.png -------------------------------------------------------------------------------- /internal/webapi/public/images/favicon/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/images/favicon/mstile-150x150.png -------------------------------------------------------------------------------- /internal/webapi/public/images/favicon/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/images/favicon/mstile-310x150.png -------------------------------------------------------------------------------- /internal/webapi/public/images/favicon/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/images/favicon/mstile-310x310.png -------------------------------------------------------------------------------- /internal/webapi/public/images/favicon/ic_launcher_xhdpi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/images/favicon/ic_launcher_xhdpi.png -------------------------------------------------------------------------------- /internal/webapi/public/images/favicon/ic_launcher_xxhdpi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/images/favicon/ic_launcher_xxhdpi.png -------------------------------------------------------------------------------- /internal/webapi/public/images/favicon/ic_launcher_xxxhdpi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/images/favicon/ic_launcher_xxxhdpi.png -------------------------------------------------------------------------------- /internal/webapi/public/images/favicon/apple-touch-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/images/favicon/apple-touch-icon-57x57.png -------------------------------------------------------------------------------- /internal/webapi/public/images/favicon/apple-touch-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/images/favicon/apple-touch-icon-60x60.png -------------------------------------------------------------------------------- /internal/webapi/public/images/favicon/apple-touch-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/images/favicon/apple-touch-icon-72x72.png -------------------------------------------------------------------------------- /internal/webapi/public/images/favicon/apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/images/favicon/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /internal/webapi/public/images/favicon/apple-touch-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/images/favicon/apple-touch-icon-114x114.png -------------------------------------------------------------------------------- /internal/webapi/public/images/favicon/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/images/favicon/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /internal/webapi/public/images/favicon/apple-touch-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/images/favicon/apple-touch-icon-144x144.png -------------------------------------------------------------------------------- /internal/webapi/public/images/favicon/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/images/favicon/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /internal/webapi/public/images/favicon/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/images/favicon/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /internal/webapi/public/css/fonts/SourceSansPro-It/SourceSansPro-It.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/css/fonts/SourceSansPro-It/SourceSansPro-It.eot -------------------------------------------------------------------------------- /internal/webapi/public/css/fonts/SourceSansPro-It/SourceSansPro-It.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/css/fonts/SourceSansPro-It/SourceSansPro-It.ttf -------------------------------------------------------------------------------- /internal/webapi/public/css/fonts/SourceSansPro-It/SourceSansPro-It.ttf.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/css/fonts/SourceSansPro-It/SourceSansPro-It.ttf.woff -------------------------------------------------------------------------------- /internal/webapi/public/css/fonts/SourceSansPro-It/SourceSansPro-It.ttf.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/css/fonts/SourceSansPro-It/SourceSansPro-It.ttf.woff2 -------------------------------------------------------------------------------- /internal/webapi/public/css/fonts/SourceCodePro-Regular/SourceCodePro-Regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/css/fonts/SourceCodePro-Regular/SourceCodePro-Regular.eot -------------------------------------------------------------------------------- /internal/webapi/public/css/fonts/SourceCodePro-Regular/SourceCodePro-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/css/fonts/SourceCodePro-Regular/SourceCodePro-Regular.ttf -------------------------------------------------------------------------------- /internal/webapi/public/css/fonts/SourceSansPro-Regular/SourceSansPro-Regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/css/fonts/SourceSansPro-Regular/SourceSansPro-Regular.eot -------------------------------------------------------------------------------- /internal/webapi/public/css/fonts/SourceSansPro-Regular/SourceSansPro-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/css/fonts/SourceSansPro-Regular/SourceSansPro-Regular.ttf -------------------------------------------------------------------------------- /internal/webapi/public/css/fonts/SourceSansPro-Semibold/SourceSansPro-Semibold.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/css/fonts/SourceSansPro-Semibold/SourceSansPro-Semibold.eot -------------------------------------------------------------------------------- /internal/webapi/public/css/fonts/SourceSansPro-Semibold/SourceSansPro-Semibold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/css/fonts/SourceSansPro-Semibold/SourceSansPro-Semibold.ttf -------------------------------------------------------------------------------- /internal/webapi/public/css/fonts/SourceCodePro-Regular/SourceCodePro-Regular.ttf.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/css/fonts/SourceCodePro-Regular/SourceCodePro-Regular.ttf.woff -------------------------------------------------------------------------------- /internal/webapi/public/css/fonts/SourceSansPro-Regular/SourceSansPro-Regular.ttf.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/css/fonts/SourceSansPro-Regular/SourceSansPro-Regular.ttf.woff -------------------------------------------------------------------------------- /internal/webapi/public/css/fonts/SourceCodePro-Regular/SourceCodePro-Regular.ttf.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/css/fonts/SourceCodePro-Regular/SourceCodePro-Regular.ttf.woff2 -------------------------------------------------------------------------------- /internal/webapi/public/css/fonts/SourceSansPro-Regular/SourceSansPro-Regular.ttf.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/css/fonts/SourceSansPro-Regular/SourceSansPro-Regular.ttf.woff2 -------------------------------------------------------------------------------- /internal/webapi/public/css/fonts/SourceSansPro-Semibold/SourceSansPro-Semibold.ttf.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/css/fonts/SourceSansPro-Semibold/SourceSansPro-Semibold.ttf.woff -------------------------------------------------------------------------------- /internal/webapi/public/css/fonts/SourceSansPro-Semibold/SourceSansPro-Semibold.ttf.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/css/fonts/SourceSansPro-Semibold/SourceSansPro-Semibold.ttf.woff2 -------------------------------------------------------------------------------- /internal/webapi/public/css/fonts/SourceSansPro-SemiboldIt/SourceSansPro-SemiboldIt.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/css/fonts/SourceSansPro-SemiboldIt/SourceSansPro-SemiboldIt.eot -------------------------------------------------------------------------------- /internal/webapi/public/css/fonts/SourceSansPro-SemiboldIt/SourceSansPro-SemiboldIt.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/css/fonts/SourceSansPro-SemiboldIt/SourceSansPro-SemiboldIt.ttf -------------------------------------------------------------------------------- /internal/webapi/public/css/fonts/SourceSansPro-SemiboldIt/SourceSansPro-SemiboldIt.ttf.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/css/fonts/SourceSansPro-SemiboldIt/SourceSansPro-SemiboldIt.ttf.woff -------------------------------------------------------------------------------- /internal/webapi/public/css/fonts/SourceSansPro-SemiboldIt/SourceSansPro-SemiboldIt.ttf.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/decred/vspd/HEAD/internal/webapi/public/css/fonts/SourceSansPro-SemiboldIt/SourceSansPro-SemiboldIt.ttf.woff2 -------------------------------------------------------------------------------- /internal/webapi/public/images/success-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /internal/webapi/homepage.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020-2022 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package webapi 6 | 7 | import ( 8 | "net/http" 9 | 10 | "github.com/gin-gonic/gin" 11 | ) 12 | 13 | func (w *WebAPI) homepage(c *gin.Context) { 14 | cacheData := c.MustGet(cacheKey).(cacheData) 15 | 16 | c.HTML(http.StatusOK, "homepage.html", gin.H{ 17 | "WebApiCache": cacheData, 18 | "WebApiCfg": w.cfg, 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /docs/release-notes/release-notes.1.1.1.md: -------------------------------------------------------------------------------- 1 | # vspd 1.1.1 2 | 3 | This is a patch release of vspd which includes the following changes: 4 | 5 | - Fix assignment to nil map ([#333](https://github.com/decred/vspd/pull/333)). 6 | 7 | ## Dependencies 8 | 9 | vspd 1.1.1 must be built with go 1.16 or later, and requires: 10 | 11 | - dcrd 1.7.1 12 | - dcrwallet 1.7.1 13 | 14 | When deploying vspd to production, always use release versions of all binaries. 15 | Neither vspd nor its dependencies should be built from master when handling 16 | mainnet tickets. 17 | -------------------------------------------------------------------------------- /internal/signal/signal_syscall.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016 The btcsuite developers 2 | // Copyright (c) 2021-2023 The Decred developers 3 | // Use of this source code is governed by an ISC 4 | // license that can be found in the LICENSE file. 5 | 6 | //go:build windows || aix || android || darwin || dragonfly || freebsd || hurd || illumos || ios || linux || netbsd || openbsd || solaris 7 | 8 | package signal 9 | 10 | import ( 11 | "syscall" 12 | ) 13 | 14 | func init() { 15 | interruptSignals = append(interruptSignals, syscall.SIGTERM, syscall.SIGHUP) 16 | } 17 | -------------------------------------------------------------------------------- /docs/release-notes/release-notes.1.2.1.md: -------------------------------------------------------------------------------- 1 | # vspd 1.2.1 2 | 3 | This is a patch release of vspd which includes the following changes: 4 | 5 | - rpc: Ignore another "duplicate tx" error ([#398](https://github.com/decred/vspd/pull/398)). 6 | 7 | ## Dependencies 8 | 9 | vspd 1.2.1 must be built with go 1.19 or later, and requires: 10 | 11 | - dcrd 1.8.0 12 | - dcrwallet 1.8.0 13 | 14 | When deploying vspd to production, always use release versions of all binaries. 15 | Neither vspd nor its dependencies should be built from master when handling 16 | mainnet tickets. 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Executables built by this repo. 2 | /cmd/vspd/vspd.exe 3 | /cmd/vspd/vspd 4 | /vspd.exe 5 | /vspd 6 | 7 | /cmd/vote-validator/vote-validator.exe 8 | /cmd/vote-validator/vote-validator 9 | /vote-validator.exe 10 | /vote-validator 11 | 12 | /cmd/v3tool/v3tool.exe 13 | /cmd/v3tool/v3tool 14 | /v3tool.exe 15 | /v3tool 16 | 17 | vote-validator.log 18 | 19 | # Harness dir. 20 | vspd-harness/ 21 | 22 | # Testing, profiling, and benchmarking artifacts 23 | cov.out 24 | *cpu.out 25 | *mem.out 26 | internal/webapi/test.db 27 | database/test.db 28 | 29 | # Go workspace 30 | go.work 31 | go.work.sum 32 | -------------------------------------------------------------------------------- /internal/webapi/templates/login.html: -------------------------------------------------------------------------------- 1 | {{ template "header" . }} 2 | 3 |
4 |

Login

5 |
6 | 7 | 8 | 9 |

{{ .FailedLoginMsg }}

10 | 11 | 12 |
13 | 14 |
15 | 16 | {{ template "footer" . }} 17 | -------------------------------------------------------------------------------- /internal/webapi/public/images/favicon/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | #091440 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /docs/release-notes/release-notes.1.3.1.md: -------------------------------------------------------------------------------- 1 | # vspd 1.3.1 2 | 3 | This is a patch release of vspd which includes the following changes: 4 | 5 | - webapi: Add missed tickets to admin page ([#451](https://github.com/decred/vspd/pull/451)). 6 | 7 | Please read the [vspd 1.3.0 release notes](https://github.com/decred/vspd/releases/tag/release-v1.3.0) 8 | for a full list of changes since vspd 1.2. 9 | 10 | ## Dependencies 11 | 12 | vspd 1.3.1 must be built with go 1.20 or later, and requires: 13 | 14 | - dcrd 1.8.0 15 | - dcrwallet 1.8.0 16 | 17 | When deploying vspd to production, always use release versions of all binaries. 18 | Neither vspd nor its dependencies should be built from master when handling 19 | mainnet tickets. 20 | -------------------------------------------------------------------------------- /internal/config/flags.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024-2025 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package config 6 | 7 | import ( 8 | "github.com/jessevdk/go-flags" 9 | ) 10 | 11 | // WriteHelp will write the application help to stdout if it has been requested 12 | // via command line options. Only the help option is evaluated, any invalid 13 | // options are ignored. The return value indicates whether the help message was 14 | // printed or not. 15 | func WriteHelp(cfg any) bool { 16 | helpOpts := flags.Options(flags.HelpFlag | flags.PrintErrors | flags.IgnoreUnknown) 17 | _, err := flags.NewParser(cfg, helpOpts).Parse() 18 | return flags.WroteHelp(err) 19 | } 20 | -------------------------------------------------------------------------------- /rpc/semver.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package rpc 6 | 7 | import "fmt" 8 | 9 | type semver struct { 10 | Major uint32 11 | Minor uint32 12 | Patch uint32 13 | } 14 | 15 | func semverCompatible(required, actual semver) bool { 16 | switch { 17 | case required.Major != actual.Major: 18 | return false 19 | case required.Minor > actual.Minor: 20 | return false 21 | case required.Minor == actual.Minor && required.Patch > actual.Patch: 22 | return false 23 | default: 24 | return true 25 | } 26 | } 27 | 28 | func (s semver) String() string { 29 | return fmt.Sprintf("%d.%d.%d", s.Major, s.Minor, s.Patch) 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2020-2024 The Decred developers 4 | 5 | Permission to use, copy, modify, and distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /docs/release-notes/release-notes.1.3.2.md: -------------------------------------------------------------------------------- 1 | # vspd 1.3.2 2 | 3 | This is a patch release of vspd which includes the following changes: 4 | 5 | - Downgrade dcrwallet dep to v3 ([#454](https://github.com/decred/vspd/pull/454)). 6 | - webapi: Wait for unknown outputs to propagate ([#455](https://github.com/decred/vspd/pull/455)). 7 | 8 | Please read the [vspd 1.3.0 release notes](https://github.com/decred/vspd/releases/tag/release-v1.3.0) 9 | for a full list of changes since vspd 1.2. 10 | 11 | ## Dependencies 12 | 13 | vspd 1.3.2 must be built with go 1.20 or later, and requires: 14 | 15 | - dcrd 1.8.0 16 | - dcrwallet 1.8.0 17 | 18 | When deploying vspd to production, always use release versions of all binaries. 19 | Neither vspd nor its dependencies should be built from master when handling 20 | mainnet tickets. 21 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | name: Go CI 6 | runs-on: ubuntu-latest 7 | strategy: 8 | matrix: 9 | go: ["1.24", "1.25"] 10 | steps: 11 | - name: Set up Go 12 | uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c #v6.1.0 13 | with: 14 | go-version: ${{ matrix.go }} 15 | - name: Check out source 16 | uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0 17 | - name: Build 18 | run: go build ./... 19 | - name: Install Linters 20 | run: "curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.6.2" 21 | - name: Test and Lint 22 | run: ./run_tests.sh 23 | -------------------------------------------------------------------------------- /cmd/v3tool/README.md: -------------------------------------------------------------------------------- 1 | # v3tool 2 | 3 | v3tool is a simple client for manual testing of vspd. 4 | It is a developer tool, not suitable for end users or production use. 5 | 6 | ## Prerequisites 7 | 8 | 1. An instance of dcrwallet which owns at least one mempool, immature or live ticket. 9 | 1. An instance of vspd to test. 10 | 11 | ## What v3tool does 12 | 13 | 1. Retrieve the pubkey from vspd. 14 | 1. Retrieve the list of owned mempool/immature/live tickets from dcrwallet. 15 | 1. For each ticket: 16 | 1. Use dcrwallet to find the tx hex, voting privkey and commitment address of the ticket. 17 | 1. Get a fee address and amount from vspd to register this ticket. 18 | 1. Create the fee tx and send it to vspd. 19 | 1. Get the ticket status. 20 | 1. Change vote choices on the ticket. 21 | 1. Get the ticket status again. 22 | -------------------------------------------------------------------------------- /cmd/vote-validator/README.md: -------------------------------------------------------------------------------- 1 | # vote-validator 2 | 3 | vote-validator is a tool for VSP admins to verify that their vspd deployment 4 | is voting correctly according to user preferences. 5 | 6 | ## What it does 7 | 8 | 1. Retrieve all voted tickets from the provided vspd database file. 9 | 1. Retrieve vote info from dcrdata for every voted ticket. 10 | 1. For the n most recently voted tickets, compare the vote choices recorded 11 | on-chain to the vote choices set by the user. 12 | 1. Write details of any discrepancies to a file for further investigation. 13 | 14 | ## How to run it 15 | 16 | Only run vote-validator using a copy of the vspd database backup file. 17 | Never use a real production database. 18 | 19 | vote-validator can be run from the repository root as such: 20 | 21 | ```no-highlight 22 | go run ./cmd/vote-validator -n 1000 -f ./vspd.db-backup 23 | ``` 24 | -------------------------------------------------------------------------------- /client/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/decred/vspd/client/v4 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/decred/dcrd/txscript/v4 v4.1.1 7 | github.com/decred/slog v1.2.0 8 | github.com/decred/vspd/types/v3 v3.0.0 9 | ) 10 | 11 | require ( 12 | github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412 // indirect 13 | github.com/dchest/siphash v1.2.3 // indirect 14 | github.com/decred/base58 v1.0.5 // indirect 15 | github.com/decred/dcrd/chaincfg/chainhash v1.0.4 // indirect 16 | github.com/decred/dcrd/crypto/blake256 v1.0.1 // indirect 17 | github.com/decred/dcrd/crypto/ripemd160 v1.0.2 // indirect 18 | github.com/decred/dcrd/dcrec v1.0.1 // indirect 19 | github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3 // indirect 20 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect 21 | github.com/decred/dcrd/wire v1.7.0 // indirect 22 | github.com/klauspost/cpuid/v2 v2.2.8 // indirect 23 | golang.org/x/sys v0.22.0 // indirect 24 | lukechampine.com/blake3 v1.3.0 // indirect 25 | ) 26 | -------------------------------------------------------------------------------- /run_tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Copyright (c) 2020-2024 The Decred developers 4 | # Use of this source code is governed by an ISC 5 | # license that can be found in the LICENSE file. 6 | # 7 | # Usage: 8 | # ./run_tests.sh 9 | 10 | set -e 11 | 12 | go version 13 | 14 | # This list needs to be updated if new submodules are added to the vspd repo. 15 | submodules="client types" 16 | 17 | # Test main module. 18 | echo "==> test main module" 19 | GORACE="halt_on_error=1" go test -race ./... 20 | 21 | # Test all submodules in a subshell. 22 | for module in $submodules 23 | do 24 | echo "==> test ${module}" 25 | ( 26 | cd $module 27 | GORACE="halt_on_error=1" go test -race . 28 | ) 29 | done 30 | 31 | # Lint main module. 32 | echo "==> lint main module" 33 | golangci-lint run 34 | 35 | # Lint all submodules in a subshell. 36 | for module in $submodules 37 | do 38 | echo "==> lint ${module}" 39 | ( 40 | cd $module 41 | golangci-lint run 42 | ) 43 | done 44 | 45 | echo "-----------------------------" 46 | echo "Tests completed successfully!" 47 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | run: 3 | timeout: 10m 4 | linters: 5 | default: none 6 | enable: 7 | - asciicheck 8 | - bidichk 9 | - containedctx 10 | - copyloopvar 11 | - dupword 12 | - durationcheck 13 | - errcheck 14 | - errchkjson 15 | - errorlint 16 | - exhaustive 17 | - fatcontext 18 | - goconst 19 | - godot 20 | - govet 21 | - ineffassign 22 | - makezero 23 | - mirror 24 | - misspell 25 | - nilerr 26 | - nilnil 27 | - nosprintfhostport 28 | - prealloc 29 | - predeclared 30 | - reassign 31 | - revive 32 | - staticcheck 33 | - tparallel 34 | - unconvert 35 | - unparam 36 | - unused 37 | - usestdlibvars 38 | - usetesting 39 | exclusions: 40 | presets: 41 | - comments 42 | - std-error-handling 43 | rules: 44 | # Ignore revive linter complaining about the name of the "types" package. 45 | - path: types/* 46 | text: 'var-naming: avoid meaningless package names' 47 | formatters: 48 | enable: 49 | - gofmt 50 | - goimports 51 | -------------------------------------------------------------------------------- /internal/webapi/public/images/favicon/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Decred", 3 | "short_name": "Decred", 4 | "icons": [ 5 | { 6 | "src": "/public/images/favicon/ic_launcher_mdpi.png?v=gT6Mc", 7 | "sizes": "48x48", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/public/images/favicon/ic_launcher_hdpi.png?v=gT6Mc", 12 | "sizes": "72x72", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "/public/images/favicon/ic_launcher_xhdpi.png?v=gT6Mc", 17 | "sizes": "96x96", 18 | "type": "image/png" 19 | }, 20 | { 21 | "src": "/public/images/favicon/ic_launcher_xxhdpi.png?v=gT6Mc", 22 | "sizes": "144x144", 23 | "type": "image/png" 24 | }, 25 | { 26 | "src": "/public/images/favicon/ic_launcher_xxxhdpi.png?v=gT6Mc", 27 | "sizes": "192x192", 28 | "type": "image/png" 29 | } 30 | ], 31 | "theme_color": "#091440", 32 | "background_color": "#091440", 33 | "start_url": "/", 34 | "display": "browser" 35 | } 36 | -------------------------------------------------------------------------------- /database/upgrade_v4.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021-2022 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package database 6 | 7 | import ( 8 | "fmt" 9 | 10 | "github.com/decred/slog" 11 | bolt "go.etcd.io/bbolt" 12 | ) 13 | 14 | func altSignAddrUpgrade(db *bolt.DB, log slog.Logger) error { 15 | log.Infof("Upgrading database to version %d", altSignAddrVersion) 16 | 17 | // Run the upgrade in a single database transaction so it can be safely 18 | // rolled back if an error is encountered. 19 | err := db.Update(func(tx *bolt.Tx) error { 20 | vspBkt := tx.Bucket(vspBktK) 21 | 22 | // Create alt sign addr bucket. 23 | _, err := vspBkt.CreateBucket(altSignAddrBktK) 24 | if err != nil { 25 | return fmt.Errorf("failed to create %s bucket: %w", altSignAddrBktK, err) 26 | } 27 | 28 | // Update database version. 29 | err = vspBkt.Put(versionK, uint32ToBytes(altSignAddrVersion)) 30 | if err != nil { 31 | return fmt.Errorf("failed to update db version: %w", err) 32 | } 33 | 34 | return nil 35 | }) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | log.Info("Upgrade completed") 41 | return nil 42 | } 43 | -------------------------------------------------------------------------------- /cmd/vspd/log.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020-2022 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "fmt" 9 | "os" 10 | "path/filepath" 11 | 12 | "github.com/decred/slog" 13 | "github.com/jrick/logrotate/rotator" 14 | ) 15 | 16 | // logWriter implements an io.Writer that outputs to both standard output and 17 | // the write-end pipe of an initialized log rotator. 18 | type logWriter struct { 19 | rotator *rotator.Rotator 20 | } 21 | 22 | func (lw logWriter) Write(p []byte) (n int, err error) { 23 | os.Stdout.Write(p) 24 | return lw.rotator.Write(p) 25 | } 26 | 27 | func newLogBackend(logDir string, appName string, maxLogSize int64, logsToKeep int) (*slog.Backend, error) { 28 | err := os.MkdirAll(logDir, 0700) 29 | if err != nil { 30 | return nil, fmt.Errorf("failed to create log directory: %w", err) 31 | } 32 | 33 | logFileName := fmt.Sprintf("%s.log", appName) 34 | logFilePath := filepath.Join(logDir, logFileName) 35 | 36 | r, err := rotator.New(logFilePath, maxLogSize*1024, false, logsToKeep) 37 | if err != nil { 38 | return nil, fmt.Errorf("failed to create log rotator: %w", err) 39 | } 40 | 41 | return slog.NewBackend(logWriter{r}), nil 42 | } 43 | -------------------------------------------------------------------------------- /internal/webapi/vspinfo.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020-2024 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package webapi 6 | 7 | import ( 8 | "time" 9 | 10 | "github.com/decred/vspd/internal/version" 11 | "github.com/decred/vspd/types/v3" 12 | "github.com/gin-gonic/gin" 13 | ) 14 | 15 | // vspInfo is the handler for "GET /api/v3/vspinfo". 16 | func (w *WebAPI) vspInfo(c *gin.Context) { 17 | cachedStats := c.MustGet(cacheKey).(cacheData) 18 | 19 | w.sendJSONResponse(types.VspInfoResponse{ 20 | APIVersions: []int64{3}, 21 | Timestamp: time.Now().Unix(), 22 | PubKey: w.signPubKey, 23 | FeePercentage: w.cfg.VSPFee, 24 | Network: w.cfg.Network.Name, 25 | VspClosed: w.cfg.VspClosed, 26 | VspClosedMsg: w.cfg.VspClosedMsg, 27 | VspdVersion: version.String(), 28 | Voting: cachedStats.Voting, 29 | Voted: cachedStats.Voted, 30 | TotalVotingWallets: cachedStats.TotalVotingWallets, 31 | VotingWalletsOnline: cachedStats.VotingWalletsOnline, 32 | Expired: cachedStats.Expired, 33 | Missed: cachedStats.Missed, 34 | BlockHeight: cachedStats.BlockHeight, 35 | NetworkProportion: cachedStats.NetworkProportion, 36 | }, c) 37 | } 38 | -------------------------------------------------------------------------------- /cmd/vspadmin/README.md: -------------------------------------------------------------------------------- 1 | # vspadmin 2 | 3 | vspadmin is a tool to perform various VSP administration tasks. 4 | 5 | ## Usage 6 | 7 | ```no-highlight 8 | vspadmin [OPTIONS] COMMAND 9 | ``` 10 | 11 | ## Options 12 | 13 | ```no-highlight 14 | --homedir= Path to application home directory. (default: /home/user/.vspd) 15 | --network=[mainnet|testnet|simnet] Decred network to use. (default: mainnet) 16 | -h, --help Show help message 17 | ``` 18 | 19 | ## Commands 20 | 21 | ### `createdatabase` 22 | 23 | Creates a new database for a new deployment of vspd. Accepts the xpub key to be 24 | used for collecting fees as a parameter. 25 | 26 | Example: 27 | 28 | ```no-highlight 29 | $ go run ./cmd/vspadmin createdatabase 30 | ``` 31 | 32 | ### `writeconfig` 33 | 34 | Writes a config file with default values to the application home directory. 35 | 36 | Example: 37 | 38 | ```no-highlight 39 | $ go run ./cmd/vspadmin writeconfig 40 | ``` 41 | 42 | ### `retirexpub` 43 | 44 | Replaces the currently used xpub with a new one. Once an xpub key has been 45 | retired it can not be used by the VSP again. 46 | 47 | **Note:** vspd must be stopped before this command can be used because it 48 | modifies values in the vspd database. 49 | 50 | Example: 51 | 52 | ```no-highlight 53 | $ go run ./cmd/vspadmin retirexpub 54 | ``` 55 | -------------------------------------------------------------------------------- /docs/release-notes/release-notes.1.0.0.md: -------------------------------------------------------------------------------- 1 | # vspd 1.0.0 2 | 3 | This is the initial release of vspd which is intended to replace 4 | [dcrstakepool](https://github.com/decred/dcrstakepool) as the reference 5 | implementation of a Decred Voting Service Provider. 6 | 7 | The [Decred blog](https://blog.decred.org/2020/06/02/A-More-Private-Way-to-Stake/) 8 | explains the motivation for creating vspd to replace dcrstakepool. 9 | 10 | ## Dependencies 11 | 12 | vspd 1.0.0 requires: 13 | 14 | - dcrd 1.6.0 15 | - dcrwallet 1.6.0 16 | 17 | When deploying vspd to production, always use release versions of all binaries. 18 | Neither vspd nor its dependencies should be built from master when handling 19 | mainnet tickets. 20 | 21 | ## New features 22 | 23 | The key features offered by this initial version are: 24 | 25 | - HTTP API 26 | - Endpoints to allows VSP users to register their tickets with the VSP, and to 27 | check on the status of registered tickets. 28 | - A status endpoint allows VSP operators to remotely monitor vspd. 29 | 30 | - Web front-end 31 | - A public home page displays various VSP statistics. 32 | - A hidden admin page allows VSP operators to search for registered tickets 33 | and download database backups. 34 | 35 | Please review the [project README](https://github.com/decred/vspd) for extended 36 | documentation, including how to use the API and a detailed deployment guide. 37 | -------------------------------------------------------------------------------- /internal/webapi/formatting_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023-2024 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package webapi 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/decred/slog" 11 | ) 12 | 13 | func TestIndentJSON(t *testing.T) { 14 | t.Parallel() 15 | 16 | // Get the actual invokable func by passing a noop logger. 17 | indentJSONFunc := indentJSON(slog.Disabled) 18 | 19 | tests := map[string]struct { 20 | input string 21 | expected string 22 | }{ 23 | "nothing": { 24 | input: "", 25 | expected: "", 26 | }, 27 | "empty": { 28 | input: "{}", 29 | expected: "{}", 30 | }, 31 | "one line JSON": { 32 | input: "{\"key\":\"value\"}", 33 | expected: "{\n \"key\": \"value\"\n}", 34 | }, 35 | "nested JSON": { 36 | input: "{\"key\":{\"key2\":\"value\"}}", 37 | expected: "{\n \"key\": {\n \"key2\": \"value\"\n }\n}", 38 | }, 39 | "invalid JSON": { 40 | input: "this is not valid json", 41 | expected: "this is not valid json", 42 | }, 43 | } 44 | 45 | for testName, test := range tests { 46 | t.Run(testName, func(t *testing.T) { 47 | t.Parallel() 48 | actual := indentJSONFunc(test.input) 49 | if actual != test.expected { 50 | t.Fatalf("expected %q, got %q", test.expected, actual) 51 | } 52 | }) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /internal/signal/signal.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2013-2014 The btcsuite developers 2 | // Copyright (c) 2021-2023 The Decred developers 3 | // Use of this source code is governed by an ISC 4 | // license that can be found in the LICENSE file. 5 | 6 | package signal 7 | 8 | import ( 9 | "context" 10 | "os" 11 | "os/signal" 12 | 13 | "github.com/decred/slog" 14 | ) 15 | 16 | // interruptSignals defines the default signals to catch in order to do a proper 17 | // shutdown. This may be modified during init depending on the platform. 18 | var interruptSignals = []os.Signal{os.Interrupt} 19 | 20 | // ShutdownListener listens for OS Signals such as SIGINT (Ctrl+C) and shutdown 21 | // requests from requestShutdown. It returns a context that is canceled when 22 | // either signal is received. 23 | func ShutdownListener(log slog.Logger) context.Context { 24 | ctx, cancel := context.WithCancel(context.Background()) 25 | go func() { 26 | interruptChannel := make(chan os.Signal, 1) 27 | signal.Notify(interruptChannel, interruptSignals...) 28 | 29 | // Listen for the initial shutdown signal. 30 | sig := <-interruptChannel 31 | log.Infof("Received signal (%s). Shutting down...", sig) 32 | 33 | cancel() 34 | 35 | // Listen for any more shutdown request and display a message so the 36 | // user knows the shutdown is in progress and the process is not hung. 37 | for { 38 | sig := <-interruptChannel 39 | log.Infof("Received signal (%s). Already shutting down...", sig) 40 | } 41 | }() 42 | return ctx 43 | } 44 | -------------------------------------------------------------------------------- /internal/webapi/templates/footer.html: -------------------------------------------------------------------------------- 1 | {{ define "footer" }} 2 | 3 | 4 | 5 |
6 | 7 | 8 | 9 |
10 | 11 | 22 | 23 | 32 | 33 |
34 |
35 | 36 | 37 | 38 | {{ end }} 39 | -------------------------------------------------------------------------------- /types/types_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022-2023 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package types 6 | 7 | import ( 8 | "errors" 9 | "testing" 10 | ) 11 | 12 | // TestAPIErrorAs ensures APIError can be unwrapped via errors.As. 13 | func TestAPIErrorAs(t *testing.T) { 14 | 15 | tests := map[string]struct { 16 | apiError error 17 | expectedKind ErrorCode 18 | expectedMessage string 19 | }{ 20 | "BadRequest error": { 21 | apiError: ErrorResponse{Message: "something went wrong", Code: ErrBadRequest}, 22 | expectedKind: ErrBadRequest, 23 | expectedMessage: "something went wrong", 24 | }, 25 | "Unknown error": { 26 | apiError: ErrorResponse{Message: "something went wrong again", Code: 999}, 27 | expectedKind: 999, 28 | expectedMessage: "something went wrong again", 29 | }, 30 | } 31 | 32 | for testName, test := range tests { 33 | t.Run(testName, func(t *testing.T) { 34 | 35 | // Ensure APIError can be unwrapped from error. 36 | var parsedError ErrorResponse 37 | if !errors.As(test.apiError, &parsedError) { 38 | t.Fatalf("unable to unwrap error") 39 | } 40 | 41 | if parsedError.Code != test.expectedKind { 42 | t.Fatalf("error was wrong kind. expected: %d actual %d", 43 | test.expectedKind, parsedError.Code) 44 | } 45 | 46 | if parsedError.Message != test.expectedMessage { 47 | t.Fatalf("error had wrong message. expected: %q actual %q", 48 | test.expectedMessage, parsedError.Message) 49 | } 50 | 51 | }) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /internal/webapi/public/images/error-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /internal/webapi/templates/vsp-stats.html: -------------------------------------------------------------------------------- 1 | {{ define "vsp-stats" }} 2 | 3 |
4 | 5 |
6 |
Live tickets
7 |
{{ comma .WebApiCache.Voting }}
8 |
9 | 10 |
11 |
Voted tickets
12 |
{{ comma .WebApiCache.Voted }}
13 |
14 | 15 |
16 |
Expired tickets
17 |
18 | {{ comma .WebApiCache.Expired }} 19 | ({{ float32ToPercent .WebApiCache.ExpiredProportion }}) 20 |
21 |
22 | 23 |
24 |
Missed tickets
25 |
26 | {{ comma .WebApiCache.Missed }} 27 | ({{ float32ToPercent .WebApiCache.MissedProportion }}) 28 |
29 |
30 | 31 |
32 |
VSP Fee
33 |
{{ .WebApiCfg.VSPFee }}%
34 |
35 | 36 |
37 |
Network Proportion
38 |
{{ float32ToPercent .WebApiCache.NetworkProportion }}
39 |
40 | 41 |
42 | 43 | {{ end }} 44 | -------------------------------------------------------------------------------- /database/encoding.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022-2023 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package database 6 | 7 | import ( 8 | "encoding/binary" 9 | "encoding/json" 10 | ) 11 | 12 | func bytesToStringMap(bytes []byte) (map[string]string, error) { 13 | if bytes == nil { 14 | return make(map[string]string), nil 15 | } 16 | 17 | var stringMap map[string]string 18 | err := json.Unmarshal(bytes, &stringMap) 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | // stringMap can still be nil here, eg. if bytes == "null". 24 | if stringMap == nil { 25 | stringMap = make(map[string]string) 26 | } 27 | 28 | return stringMap, nil 29 | } 30 | 31 | func stringMapToBytes(stringMap map[string]string) []byte { 32 | // json.Marshal will only return an error if passed an invalid struct. 33 | // Structs are all known and hard-coded, so errors are never expected here. 34 | bytes, err := json.Marshal(stringMap) 35 | if err != nil { 36 | panic(err) 37 | } 38 | return bytes 39 | } 40 | 41 | func int64ToBytes(i int64) []byte { 42 | bytes := make([]byte, 8) 43 | binary.LittleEndian.PutUint64(bytes, uint64(i)) 44 | return bytes 45 | } 46 | 47 | func bytesToInt64(bytes []byte) int64 { 48 | return int64(binary.LittleEndian.Uint64(bytes)) 49 | } 50 | 51 | func uint32ToBytes(i uint32) []byte { 52 | bytes := make([]byte, 4) 53 | binary.LittleEndian.PutUint32(bytes, i) 54 | return bytes 55 | } 56 | 57 | func bytesToUint32(bytes []byte) uint32 { 58 | return binary.LittleEndian.Uint32(bytes) 59 | } 60 | 61 | func bytesToBool(bytes []byte) bool { 62 | return bytes[0] == 1 63 | } 64 | 65 | func boolToBytes(b bool) []byte { 66 | if b { 67 | return []byte{1} 68 | } 69 | 70 | return []byte{0} 71 | } 72 | -------------------------------------------------------------------------------- /rpc/notifs.go: -------------------------------------------------------------------------------- 1 | package rpc 2 | 3 | import ( 4 | "bytes" 5 | "encoding/hex" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | 10 | "github.com/decred/dcrd/wire" 11 | "github.com/decred/slog" 12 | ) 13 | 14 | type blockConnectedHandler struct { 15 | blockConnected chan *wire.BlockHeader 16 | log slog.Logger 17 | } 18 | 19 | // Notify is called every time a notification is received from dcrd client. 20 | // A wsrpc.Client will never call Notify concurrently. Notify should not return 21 | // an error because that will cause the client to close and no further 22 | // notifications will be received until a new connection is established. 23 | func (n *blockConnectedHandler) Notify(method string, msg json.RawMessage) error { 24 | if method != "blockconnected" { 25 | return nil 26 | } 27 | 28 | header, err := parseBlockConnected(msg) 29 | if err != nil { 30 | n.log.Errorf("Failed to parse dcrd block notification: %v", err) 31 | return nil 32 | } 33 | 34 | n.blockConnected <- header 35 | 36 | return nil 37 | } 38 | 39 | func (n *blockConnectedHandler) Close() error { 40 | return nil 41 | } 42 | 43 | // parseBlockConnected extracts the block header from a 44 | // blockconnected JSON-RPC notification. 45 | func parseBlockConnected(msg json.RawMessage) (*wire.BlockHeader, error) { 46 | var notif []string 47 | err := json.Unmarshal(msg, ¬if) 48 | if err != nil { 49 | return nil, fmt.Errorf("json unmarshal error: %w", err) 50 | } 51 | 52 | if len(notif) == 0 { 53 | return nil, errors.New("notification is empty") 54 | } 55 | 56 | var header wire.BlockHeader 57 | err = header.Deserialize(hex.NewDecoder(bytes.NewReader([]byte(notif[0])))) 58 | if err != nil { 59 | return nil, fmt.Errorf("error creating block header from bytes: %w", err) 60 | } 61 | 62 | return &header, nil 63 | } 64 | -------------------------------------------------------------------------------- /database/votechange_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020-2024 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package database 6 | 7 | import ( 8 | "reflect" 9 | "testing" 10 | ) 11 | 12 | func exampleRecord() VoteChangeRecord { 13 | return VoteChangeRecord{ 14 | Request: "Request", 15 | RequestSignature: "RequestSignature", 16 | Response: "Response", 17 | ResponseSignature: "ResponseSignature", 18 | } 19 | } 20 | 21 | func testVoteChangeRecords(t *testing.T) { 22 | const hash = "MyHash" 23 | record := exampleRecord() 24 | 25 | // Insert a record into the database. 26 | err := db.SaveVoteChange(hash, record) 27 | if err != nil { 28 | t.Fatalf("error storing vote change record in database: %v", err) 29 | } 30 | 31 | // Retrieve record and check values. 32 | retrieved, err := db.GetVoteChanges(hash) 33 | if err != nil { 34 | t.Fatalf("error retrieving vote change records: %v", err) 35 | } 36 | 37 | if len(retrieved) != 1 || !reflect.DeepEqual(retrieved[0], record) { 38 | t.Fatal("retrieved record didnt match expected") 39 | } 40 | 41 | // Insert some more records, giving us one greater than the limit. 42 | for range maxVoteChangeRecords { 43 | err = db.SaveVoteChange(hash, record) 44 | if err != nil { 45 | t.Fatalf("error storing vote change record in database: %v", err) 46 | } 47 | } 48 | 49 | // Retrieve records. 50 | retrieved, err = db.GetVoteChanges(hash) 51 | if err != nil { 52 | t.Fatalf("error retrieving vote change records: %v", err) 53 | } 54 | 55 | // Oldest record should have been deleted. 56 | if len(retrieved) != maxVoteChangeRecords { 57 | t.Fatalf("vote change record limit breached") 58 | } 59 | 60 | if _, ok := retrieved[0]; ok { 61 | t.Fatalf("oldest vote change record should have been deleted") 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /cmd/vote-validator/results.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "fmt" 9 | "os" 10 | ) 11 | 12 | type results struct { 13 | badVotes []*votedTicket 14 | noPreferences []*votedTicket 15 | wrongVersion []*votedTicket 16 | } 17 | 18 | func (r *results) writeFile(path string) (bool, error) { 19 | 20 | if len(r.badVotes) == 0 && 21 | len(r.noPreferences) == 0 && 22 | len(r.wrongVersion) == 0 { 23 | return false, nil 24 | } 25 | 26 | // Open a log file. 27 | f, err := os.Create(path) 28 | if err != nil { 29 | return false, fmt.Errorf("opening log file failed: %w", err) 30 | } 31 | 32 | write := func(f *os.File, format string, a ...any) { 33 | _, err := fmt.Fprintf(f, format+"\n", a...) 34 | if err != nil { 35 | f.Close() 36 | panic(fmt.Sprintf("writing to log file failed: %v", err)) 37 | } 38 | } 39 | 40 | if len(r.badVotes) > 0 { 41 | write(f, "Tickets with bad votes:") 42 | for _, t := range r.badVotes { 43 | write(f, 44 | "Hash: %s VoteHeight: %d ExpectedVote: %v ActualVote: %v", 45 | t.ticket.Hash, t.voteHeight, t.ticket.VoteChoices, t.vote, 46 | ) 47 | } 48 | write(f, "\n") 49 | } 50 | 51 | if len(r.wrongVersion) > 0 { 52 | write(f, "Tickets with the wrong vote version:") 53 | for _, t := range r.wrongVersion { 54 | write(f, 55 | "Hash: %s", 56 | t.ticket.Hash, 57 | ) 58 | } 59 | write(f, "\n") 60 | } 61 | 62 | if len(r.noPreferences) > 0 { 63 | write(f, "Tickets with no user set vote preferences:") 64 | for _, t := range r.noPreferences { 65 | write(f, 66 | "Hash: %s", 67 | t.ticket.Hash, 68 | ) 69 | } 70 | write(f, "\n") 71 | } 72 | 73 | err = f.Close() 74 | if err != nil { 75 | return false, fmt.Errorf("closing log file failed: %w", err) 76 | } 77 | 78 | return true, nil 79 | } 80 | -------------------------------------------------------------------------------- /database/upgrade_v2.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021-2022 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package database 6 | 7 | import ( 8 | "encoding/json" 9 | "fmt" 10 | 11 | "github.com/decred/slog" 12 | bolt "go.etcd.io/bbolt" 13 | ) 14 | 15 | func removeOldFeeTxUpgrade(db *bolt.DB, log slog.Logger) error { 16 | log.Infof("Upgrading database to version %d", removeOldFeeTxVersion) 17 | 18 | // Run the upgrade in a single database transaction so it can be safely 19 | // rolled back if an error is encountered. 20 | err := db.Update(func(tx *bolt.Tx) error { 21 | vspBkt := tx.Bucket(vspBktK) 22 | ticketBkt := vspBkt.Bucket(ticketBktK) 23 | 24 | count := 0 25 | err := ticketBkt.ForEach(func(k, v []byte) error { 26 | // Deserialize the old ticket. 27 | var ticket v1Ticket 28 | err := json.Unmarshal(v, &ticket) 29 | if err != nil { 30 | return fmt.Errorf("could not unmarshal ticket: %w", err) 31 | } 32 | 33 | // Remove the fee tx hex if the tx is already confirmed. 34 | if ticket.FeeTxStatus == FeeConfirmed { 35 | count++ 36 | ticket.FeeTxHex = "" 37 | ticketBytes, err := json.Marshal(ticket) 38 | if err != nil { 39 | return fmt.Errorf("could not marshal ticket: %w", err) 40 | } 41 | 42 | err = ticketBkt.Put(k, ticketBytes) 43 | if err != nil { 44 | return fmt.Errorf("could not put updated ticket: %w", err) 45 | } 46 | } 47 | 48 | return nil 49 | }) 50 | if err != nil { 51 | return err 52 | } 53 | 54 | log.Infof("Dropped %d unnecessary raw transactions", count) 55 | 56 | // Update database version. 57 | err = vspBkt.Put(versionK, uint32ToBytes(removeOldFeeTxVersion)) 58 | if err != nil { 59 | return fmt.Errorf("failed to update db version: %w", err) 60 | } 61 | 62 | return nil 63 | }) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | log.Info("Upgrade completed") 69 | return nil 70 | } 71 | -------------------------------------------------------------------------------- /docs/two-way-accountability.md: -------------------------------------------------------------------------------- 1 | # Two-way Accountability 2 | 3 | In order to support two-way accountability, all vspd requests must be signed 4 | with a private key corresponding to the relevant ticket, and all vspd responses 5 | are signed by with a private key known only by the server. 6 | 7 | ## Client 8 | 9 | ### Client Request Signatures 10 | 11 | Every client request which references a ticket should include a HTTP header 12 | `VSP-Client-Signature`. The value of this header must be a signature of the 13 | request body, signed with the commitment address of the referenced ticket, or 14 | the alternate signature address if set. 15 | 16 | ### Client Accountability Example 17 | 18 | A misbehaving user may attempt to discredit a VSP operator by falsely claiming 19 | that the VSP did not vote a ticket according to the voting preferences selected 20 | by the user. 21 | 22 | In this case, the VSP operator can reveal the request signed by the user which 23 | set the voting preferences, and demonstrate that this matches the voting 24 | preferences which were recorded on the blockchain. It would then be incumbent on 25 | the user to provide a signed request/response pair with a later timestamp to 26 | demonstrate that the operator is being dishonest. 27 | 28 | ## Server 29 | 30 | ### Server Response Signatures 31 | 32 | When vspd is started for the first time, it generates a ed25519 keypair and 33 | stores it in the database. This key is used to sign all API responses, and the 34 | signature is included in the response header `VSP-Server-Signature`. 35 | 36 | ### Server Accountability Example 37 | 38 | A misbehaving server may fail to vote several tickets for which a user has paid 39 | valid fees. 40 | 41 | In this case, the user can reveal responses signed by the server which 42 | demonstrate that the server has acknowledged receipt of the fees, and all 43 | information necessary to have voted the ticket. It would then be incumbent on 44 | the server to explain why these tickets were not voted. 45 | -------------------------------------------------------------------------------- /internal/webapi/middleware_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package webapi 6 | 7 | import ( 8 | "net/http" 9 | "net/http/httptest" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/gorilla/sessions" 14 | ) 15 | 16 | // NOTE: This test does not test any vspd code. 17 | // 18 | // If the cookie store secret changes unexpectedly (common during development) 19 | // the securecookie library returns an error with a hard-coded, non-exported 20 | // string. 21 | // 22 | // "securecookie: the value is not valid" 23 | // 24 | // TestCookieSecretError ensures the string returned by the lib does not change, 25 | // which is important because vspd checks for the error using string comparison. 26 | func TestCookieSecretError(t *testing.T) { 27 | 28 | // Create a cookie store, get a cookie from it. 29 | 30 | store := sessions.NewCookieStore([]byte("first secret")) 31 | 32 | req, err := http.NewRequest(http.MethodPost, "/", nil) 33 | if err != nil { 34 | t.Fatal(err) 35 | } 36 | 37 | session, err := store.Get(req, "key") 38 | if err != nil { 39 | t.Fatal(err) 40 | } 41 | 42 | w := httptest.NewRecorder() 43 | err = store.Save(req, w, session) 44 | if err != nil { 45 | t.Fatal(err) 46 | } 47 | 48 | cookie := w.Result().Header["Set-Cookie"][0] 49 | 50 | // Create another cookie store using a different secret, send cookie from 51 | // first store to the new store, confirm error is correct. 52 | 53 | store2 := sessions.NewCookieStore([]byte("second secret")) 54 | 55 | req2, err := http.NewRequest(http.MethodPost, "/", nil) 56 | if err != nil { 57 | t.Fatal(err) 58 | } 59 | req2.Header.Add("Cookie", cookie) 60 | 61 | _, err = store2.Get(req2, "key") 62 | 63 | if !strings.Contains(err.Error(), invalidCookieErr) { 64 | t.Fatalf("securecookie library returned unexpected error, wanted %q, got %q", 65 | invalidCookieErr, err.Error()) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /database/encoding_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022-2024 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package database 6 | 7 | import ( 8 | "reflect" 9 | "testing" 10 | ) 11 | 12 | func TestBytesToStringMap(t *testing.T) { 13 | t.Parallel() 14 | 15 | var tests = []struct { 16 | name string 17 | input []byte 18 | expect map[string]string 19 | expectErr bool 20 | }{ 21 | { 22 | name: "Empty map on nil bytes", 23 | input: nil, 24 | expect: map[string]string{}, 25 | expectErr: false, 26 | }, 27 | { 28 | name: "Empty map on empty json map", 29 | input: []byte("{}"), 30 | expect: map[string]string{}, 31 | expectErr: false, 32 | }, 33 | { 34 | name: "Empty map on null", 35 | input: []byte("null"), 36 | expect: map[string]string{}, 37 | expectErr: false, 38 | }, 39 | { 40 | name: "Correct values with valid json", 41 | input: []byte("{\"key\":\"value\"}"), 42 | expect: map[string]string{"key": "value"}, 43 | expectErr: false, 44 | }, 45 | { 46 | name: "Error on no bytes", 47 | input: []byte(""), 48 | expect: nil, 49 | expectErr: true, 50 | }, 51 | { 52 | name: "Error on invalid json", 53 | input: []byte("invalid json"), 54 | expect: nil, 55 | expectErr: true, 56 | }, 57 | { 58 | name: "Error on non-map json", 59 | input: []byte("[\"not a map\"]"), 60 | expect: nil, 61 | expectErr: true, 62 | }, 63 | } 64 | 65 | for _, test := range tests { 66 | t.Run(test.name, func(t *testing.T) { 67 | t.Parallel() 68 | result, err := bytesToStringMap(test.input) 69 | if !reflect.DeepEqual(test.expect, result) { 70 | t.Fatalf("expected %v, got %v", test.expect, result) 71 | } 72 | if test.expectErr != (err != nil) { 73 | t.Fatalf("expected err=%t, got %v", test.expectErr, err) 74 | } 75 | }) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /internal/webapi/templates/homepage.html: -------------------------------------------------------------------------------- 1 | {{ template "header" . }} 2 | 3 |
4 |
5 | 6 | {{ if .WebApiCfg.VspClosed }} 7 |
8 |

9 | This Voting Service Provider is closed 10 |

11 |

12 | {{ .WebApiCfg.VspClosedMsg }} 13 |

14 |

15 | A closed VSP will still vote on tickets with already paid fees, but will not accept new any tickets. 16 | Visit decred.org to find a new VSP. 17 |

18 |
19 | {{ end }} 20 | 21 | {{ if not (eq .WebApiCfg.Network.Name "mainnet") }} 22 |
23 | This Voting Service Provider is running on {{ .WebApiCfg.Network.Name }}. 24 | Visit decred.org to find a list of mainnet VSPs. 25 |
26 | {{ end }} 27 | 28 |

VSP Overview

29 | 30 |

A Voting Service Provider (VSP) maintains a pool of always-online voting wallets, 31 | and allows Decred ticket holders to use these wallets to vote their tickets in exchange for a small fee. 32 | VSPs are completely non-custodial - they never hold, manage, or have access to any user funds. 33 | Visit docs.decred.org 34 | to find out more about VSPs, tickets, and voting. 35 |

36 | 37 | {{ template "vsp-stats" . }} 38 | 39 |
40 |
41 | 42 | {{ template "footer" . }} 43 | -------------------------------------------------------------------------------- /internal/webapi/ticketstatus.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020-2024 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package webapi 6 | 7 | import ( 8 | "time" 9 | 10 | "github.com/decred/vspd/database" 11 | "github.com/decred/vspd/types/v3" 12 | "github.com/gin-gonic/gin" 13 | "github.com/gin-gonic/gin/binding" 14 | ) 15 | 16 | // ticketStatus is the handler for "POST /api/v3/ticketstatus". 17 | func (w *WebAPI) ticketStatus(c *gin.Context) { 18 | const funcName = "ticketStatus" 19 | 20 | // Get values which have been added to context by middleware. 21 | ticket := c.MustGet(ticketKey).(database.Ticket) 22 | knownTicket := c.MustGet(knownTicketKey).(bool) 23 | reqBytes := c.MustGet(requestBytesKey).([]byte) 24 | 25 | if !knownTicket { 26 | w.log.Warnf("%s: Unknown ticket (clientIP=%s)", funcName, c.ClientIP()) 27 | w.sendError(types.ErrUnknownTicket, c) 28 | return 29 | } 30 | 31 | var request types.TicketStatusRequest 32 | if err := binding.JSON.BindBody(reqBytes, &request); err != nil { 33 | w.log.Warnf("%s: Bad request (clientIP=%s): %v", funcName, c.ClientIP(), err) 34 | w.sendErrorWithMsg(err.Error(), types.ErrBadRequest, c) 35 | return 36 | } 37 | 38 | // Get altSignAddress from database 39 | altSignAddrData, err := w.db.AltSignAddrData(ticket.Hash) 40 | if err != nil { 41 | w.log.Errorf("%s: db.AltSignAddrData error (ticketHash=%s): %v", funcName, ticket.Hash, err) 42 | w.sendError(types.ErrInternalError, c) 43 | return 44 | } 45 | 46 | altSignAddr := "" 47 | if altSignAddrData != nil { 48 | altSignAddr = altSignAddrData.AltSignAddr 49 | } 50 | 51 | w.sendJSONResponse(types.TicketStatusResponse{ 52 | Timestamp: time.Now().Unix(), 53 | Request: reqBytes, 54 | TicketConfirmed: ticket.Confirmed, 55 | FeeTxStatus: string(ticket.FeeTxStatus), 56 | FeeTxHash: ticket.FeeTxHash, 57 | AltSignAddress: altSignAddr, 58 | VoteChoices: ticket.VoteChoices, 59 | TreasuryPolicy: ticket.TreasuryPolicy, 60 | TSpendPolicy: ticket.TSpendPolicy, 61 | }, c) 62 | } 63 | -------------------------------------------------------------------------------- /docs/listing.md: -------------------------------------------------------------------------------- 1 | # Listing a VSP on decred.org 2 | 3 | Public VSP servers are a key part of the Decred infrastructure as they make 4 | Proof-of-Stake far more accessible for the average user. 5 | It is therefore desirable to increase the number of public VSPs listed in 6 | Decrediton and on [decred.org](https://decred.org/vsp) in order to promote 7 | decentralization and improve the stability of the Decred network. 8 | 9 | ## Operator Requirements 10 | 11 | * Familiarity with system administration work on Linux. 12 | * Ability to compile from source, setting up and maintaining `dcrd` and 13 | `dcrwallet`. 14 | * Willingness to stay in touch with the Decred community for important news and 15 | updates. A private channel on [Matrix](https://chat.decred.org) exists for 16 | this purpose. 17 | * Availability to update VSP binaries when new releases are produced. 18 | * Operators should ideally be pairs of individuals or larger groups, so that the 19 | unavailability of a single person does not lead to extended outages in their 20 | absence. 21 | * Ability to effectively communicate in English. 22 | 23 | ## Deployment Requirements 24 | 25 | * At least one machine dedicated to hosting the web front end, handling web 26 | connections from VSP users. 27 | * At least three dedicated machines hosting voting wallets and a local instance 28 | of dcrd. 29 | * The machines used to host the voting wallets must be spread across 3 or more 30 | physically separate locations. 31 | * The web frontend must have an IP that is distinct from those of the voting 32 | wallets, and is ideally located in another physical location. 33 | * The VSP must be run on testnet for 1 week to confirm it is working properly. 34 | Uptime and number of votes made versus missed will be checked. 35 | * The VSP must be run on mainnet in test mode (no public access) until a VSP 36 | operator demonstrates they have successfully voted 1 ticket of their own using 37 | the VSP. 38 | * The operator must have an adequate monitoring solution in place, ideally 39 | alerting on server downtime and application error logging. 40 | 41 | ## Further Information 42 | 43 | For further support you can contact the [Decred community](https://decred.org/community). 44 | -------------------------------------------------------------------------------- /cmd/vote-validator/dcrdata.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021-2024 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "bytes" 9 | "context" 10 | "encoding/json" 11 | "fmt" 12 | "io" 13 | "net/http" 14 | 15 | dcrdtypes "github.com/decred/dcrd/rpc/jsonrpc/types/v4" 16 | ) 17 | 18 | type txns struct { 19 | Transactions []string `json:"transactions"` 20 | } 21 | 22 | type txInputID struct { 23 | Hash string `json:"hash"` 24 | Index uint32 `json:"vin_index"` 25 | } 26 | 27 | type vout struct { 28 | Value float64 `json:"value"` 29 | N uint32 `json:"n"` 30 | Version uint16 `json:"version"` 31 | ScriptPubKeyDecoded dcrdtypes.ScriptPubKeyResult `json:"scriptPubKey"` 32 | Spend *txInputID `json:"spend"` 33 | } 34 | 35 | type tx struct { 36 | TxID string `json:"txid"` 37 | Size int32 `json:"size"` 38 | Version int32 `json:"version"` 39 | Locktime uint32 `json:"locktime"` 40 | Expiry uint32 `json:"expiry"` 41 | Vin []dcrdtypes.Vin `json:"vin"` 42 | Vout []vout `json:"vout"` 43 | Confirmations int64 `json:"confirmations"` 44 | } 45 | 46 | type dcrdataClient struct { 47 | URL string 48 | } 49 | 50 | func (d *dcrdataClient) txns(ctx context.Context, txnHashes []string, spends bool) ([]tx, error) { 51 | jsonData, err := json.Marshal(txns{ 52 | Transactions: txnHashes, 53 | }) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | url := fmt.Sprintf("%s/api/txs?spends=%t", d.URL, spends) 59 | request, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(jsonData)) 60 | if err != nil { 61 | return nil, err 62 | } 63 | request.Header.Set("Content-Type", "application/json; charset=UTF-8") 64 | 65 | client := &http.Client{} 66 | resp, err := client.Do(request) 67 | if err != nil { 68 | return nil, err 69 | } 70 | defer resp.Body.Close() 71 | 72 | if resp.StatusCode != http.StatusOK { 73 | return nil, fmt.Errorf("dcrdata response: %v", resp.Status) 74 | } 75 | 76 | body, err := io.ReadAll(resp.Body) 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | var txns []tx 82 | err = json.Unmarshal(body, &txns) 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | return txns, nil 88 | } 89 | -------------------------------------------------------------------------------- /database/upgrade_v5.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2024 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package database 6 | 7 | import ( 8 | "errors" 9 | "fmt" 10 | 11 | "github.com/decred/slog" 12 | bolt "go.etcd.io/bbolt" 13 | ) 14 | 15 | func xPubBucketUpgrade(db *bolt.DB, log slog.Logger) error { 16 | log.Infof("Upgrading database to version %d", xPubBucketVersion) 17 | 18 | // feeXPub is the key which was used prior to this upgrade to store the xpub 19 | // in the root bucket. 20 | feeXPubK := []byte("feeXPub") 21 | 22 | // lastaddressindex is the key which was used prior to this upgrade to store 23 | // the index of the last used address in the root bucket. 24 | lastAddressIndexK := []byte("lastaddressindex") 25 | 26 | // Run the upgrade in a single database transaction so it can be safely 27 | // rolled back if an error is encountered. 28 | err := db.Update(func(tx *bolt.Tx) error { 29 | vspBkt := tx.Bucket(vspBktK) 30 | ticketBkt := vspBkt.Bucket(ticketBktK) 31 | 32 | // Retrieve the current xpub. 33 | xpubBytes := vspBkt.Get(feeXPubK) 34 | if xpubBytes == nil { 35 | return errors.New("xpub not found") 36 | } 37 | feeXPub := string(xpubBytes) 38 | 39 | // Retrieve the current last addr index. Could be nil if this xpub was 40 | // never used. 41 | idxBytes := vspBkt.Get(lastAddressIndexK) 42 | var idx uint32 43 | if idxBytes != nil { 44 | idx = bytesToUint32(idxBytes) 45 | } 46 | 47 | // Delete the old values from the database. 48 | err := vspBkt.Delete(feeXPubK) 49 | if err != nil { 50 | return fmt.Errorf("could not delete xpub: %w", err) 51 | } 52 | err = vspBkt.Delete(lastAddressIndexK) 53 | if err != nil { 54 | return fmt.Errorf("could not delete last addr idx: %w", err) 55 | } 56 | 57 | // Insert the key into the bucket. 58 | newXpub := FeeXPub{ 59 | ID: 0, 60 | Key: feeXPub, 61 | LastUsedIdx: idx, 62 | Retired: 0, 63 | } 64 | err = insertFeeXPub(tx, newXpub) 65 | if err != nil { 66 | return fmt.Errorf("failed to store xpub in new bucket: %w", err) 67 | } 68 | 69 | // Update all existing tickets with xpub key ID 0. 70 | err = ticketBkt.ForEachBucket(func(k []byte) error { 71 | return ticketBkt.Bucket(k).Put(feeAddressXPubIDK, uint32ToBytes(0)) 72 | }) 73 | if err != nil { 74 | return fmt.Errorf("setting ticket xpub ID to 0 failed: %w", err) 75 | } 76 | 77 | // Update database version. 78 | err = vspBkt.Put(versionK, uint32ToBytes(xPubBucketVersion)) 79 | if err != nil { 80 | return fmt.Errorf("failed to update db version: %w", err) 81 | } 82 | 83 | return nil 84 | }) 85 | if err != nil { 86 | return err 87 | } 88 | 89 | log.Info("Upgrade completed") 90 | 91 | return nil 92 | } 93 | -------------------------------------------------------------------------------- /internal/webapi/formatting.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020-2024 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package webapi 6 | 7 | import ( 8 | "bytes" 9 | "encoding/json" 10 | "fmt" 11 | "strings" 12 | "time" 13 | 14 | "github.com/decred/dcrd/dcrutil/v4" 15 | "github.com/decred/slog" 16 | "github.com/dustin/go-humanize" 17 | ) 18 | 19 | func addressURL(blockExplorerURL string) func(string) string { 20 | return func(addr string) string { 21 | return fmt.Sprintf("%s/address/%s", blockExplorerURL, addr) 22 | } 23 | } 24 | 25 | func txURL(blockExplorerURL string) func(string) string { 26 | return func(txID string) string { 27 | return fmt.Sprintf("%s/tx/%s", blockExplorerURL, txID) 28 | } 29 | } 30 | 31 | func blockURL(blockExplorerURL string) func(int64) string { 32 | return func(height int64) string { 33 | return fmt.Sprintf("%s/block/%d", blockExplorerURL, height) 34 | } 35 | } 36 | 37 | // dateTime returns a human readable representation of the provided unix 38 | // timestamp. It includes the local timezone of the server so use on public 39 | // webpages is not recommended. 40 | func dateTime(t int64) string { 41 | return time.Unix(t, 0).Format("2 Jan 2006 15:04:05 MST") 42 | } 43 | 44 | // timeAgo compares the provided unix timestamp to the current time to return a 45 | // string like "3 minutes ago". 46 | func timeAgo(t time.Time) string { 47 | return humanize.Time(t) 48 | } 49 | 50 | func stripWss(input string) string { 51 | input = strings.ReplaceAll(input, "wss://", "") 52 | input = strings.ReplaceAll(input, "/ws", "") 53 | return input 54 | } 55 | 56 | // indentJSON returns a func which uses whitespace to format a provided JSON 57 | // string. If the parameter is invalid JSON, an error will be logged and the 58 | // param will be returned unaltered. 59 | func indentJSON(log slog.Logger) func(string) string { 60 | return func(input string) string { 61 | var indented bytes.Buffer 62 | err := json.Indent(&indented, []byte(input), "", " ") 63 | if err != nil { 64 | log.Errorf("Failed to indent JSON: %w", err) 65 | return input 66 | } 67 | 68 | return indented.String() 69 | } 70 | } 71 | 72 | func atomsToDCR(atoms int64) string { 73 | return dcrutil.Amount(atoms).String() 74 | } 75 | 76 | func float32ToPercent(input float32) string { 77 | return fmt.Sprintf("%.2f%%", input*100) 78 | } 79 | 80 | // pluralize suffixes the provided noun with "s" if n is not 1, then 81 | // concatenates n and noun with a space between them. For example: 82 | // 83 | // (0, "biscuit") will return "0 biscuits" 84 | // (1, "biscuit") will return "1 biscuit" 85 | // (3, "biscuit") will return "3 biscuits" 86 | func pluralize(n int, noun string) string { 87 | if n != 1 { 88 | noun += "s" 89 | } 90 | return fmt.Sprintf("%d %s", n, noun) 91 | } 92 | -------------------------------------------------------------------------------- /internal/webapi/addressgenerator.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020-2024 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package webapi 6 | 7 | import ( 8 | "errors" 9 | 10 | "github.com/decred/dcrd/chaincfg/v3" 11 | "github.com/decred/dcrd/hdkeychain/v3" 12 | "github.com/decred/dcrd/txscript/v4/stdaddr" 13 | "github.com/decred/slog" 14 | "github.com/decred/vspd/database" 15 | ) 16 | 17 | type addressGenerator struct { 18 | external *hdkeychain.ExtendedKey 19 | netParams *chaincfg.Params 20 | lastUsedIndex uint32 21 | feeXPubID uint32 22 | log slog.Logger 23 | } 24 | 25 | func newAddressGenerator(xPub database.FeeXPub, netParams *chaincfg.Params, log slog.Logger) (*addressGenerator, error) { 26 | xPubKey, err := hdkeychain.NewKeyFromString(xPub.Key, netParams) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | if xPubKey.IsPrivate() { 32 | return nil, errors.New("not a public key") 33 | } 34 | 35 | // Derive the extended key for the external chain. 36 | external, err := xPubKey.Child(0) 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | return &addressGenerator{ 42 | external: external, 43 | netParams: netParams, 44 | lastUsedIndex: xPub.LastUsedIdx, 45 | feeXPubID: xPub.ID, 46 | log: log, 47 | }, nil 48 | } 49 | 50 | func (m *addressGenerator) xPubID() uint32 { 51 | return m.feeXPubID 52 | } 53 | 54 | // nextAddress increments the last used address counter and returns a new 55 | // address. It will skip any address index which causes an ErrInvalidChild. 56 | // Not safe for concurrent access. 57 | func (m *addressGenerator) nextAddress() (string, uint32, error) { 58 | var key *hdkeychain.ExtendedKey 59 | var err error 60 | 61 | // There is a small chance that generating addresses for a given index can 62 | // fail with ErrInvalidChild, so loop until we find an index which works. 63 | // See the hdkeychain.ExtendedKey.Child docs for more info. 64 | invalidChildren := 0 65 | for { 66 | m.lastUsedIndex++ 67 | key, err = m.external.Child(m.lastUsedIndex) 68 | if err != nil { 69 | if errors.Is(err, hdkeychain.ErrInvalidChild) { 70 | invalidChildren++ 71 | m.log.Warnf("Generating address for index %d failed: %v", m.lastUsedIndex, err) 72 | // If this happens 3 times, something is seriously wrong, so 73 | // return an error. 74 | if invalidChildren > 2 { 75 | return "", 0, errors.New("multiple invalid children generated for key") 76 | } 77 | continue 78 | } 79 | return "", 0, err 80 | } 81 | break 82 | } 83 | 84 | // Convert to a standard pay-to-pubkey-hash address. 85 | pkHash := stdaddr.Hash160(key.SerializedPubKey()) 86 | addr, err := stdaddr.NewAddressPubKeyHashEcdsaSecp256k1V0(pkHash, m.netParams) 87 | if err != nil { 88 | return "", 0, err 89 | } 90 | 91 | return addr.String(), m.lastUsedIndex, nil 92 | } 93 | -------------------------------------------------------------------------------- /internal/webapi/public/css/fonts.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "vspd-code"; 3 | src: 4 | url("/public/css/fonts/SourceCodePro-Regular/SourceCodePro-Regular.ttf.woff2") format("woff2"), 5 | url("/public/css/fonts/SourceCodePro-Regular/SourceCodePro-Regular.ttf.woff") format("woff"), 6 | url("/public/css/fonts/SourceCodePro-Regular/SourceCodePro-Regular.ttf") format("truetype"), 7 | url("/public/css/fonts/SourceCodePro-Regular/SourceCodePro-Regular.eot") format("embedded-opentype"); 8 | font-display: swap; 9 | } 10 | 11 | @font-face { 12 | font-family: "vspd"; 13 | src: 14 | url("/public/css/fonts/SourceSansPro-Regular/SourceSansPro-Regular.ttf.woff2") format("woff2"), 15 | url("/public/css/fonts/SourceSansPro-Regular/SourceSansPro-Regular.ttf.woff") format("woff"), 16 | url("/public/css/fonts/SourceSansPro-Regular/SourceSansPro-Regular.ttf") format("truetype"), 17 | url("/public/css/fonts/SourceSansPro-Regular/SourceSansPro-Regular.eot") format("embedded-opentype"); 18 | font-display: swap; 19 | } 20 | 21 | @font-face { 22 | font-family: "vspd"; 23 | src: 24 | url("/public/css/fonts/SourceSansPro-Semibold/SourceSansPro-Semibold.ttf.woff2") format("woff2"), 25 | url("/public/css/fonts/SourceSansPro-Semibold/SourceSansPro-Semibold.ttf.woff") format("woff"), 26 | url("/public/css/fonts/SourceSansPro-Semibold/SourceSansPro-Semibold.ttf") format("truetype"), 27 | url("/public/css/fonts/SourceSansPro-Semibold/SourceSansPro-Semibold.eot") format("embedded-opentype"); 28 | font-weight: bold; 29 | font-display: swap; 30 | } 31 | 32 | @font-face { 33 | font-family: "vspd"; 34 | src: 35 | url("/public/css/fonts/SourceSansPro-It/SourceSansPro-It.ttf.woff2") format("woff2"), 36 | url("/public/css/fonts/SourceSansPro-It/SourceSansPro-It.ttf.woff") format("woff"), 37 | url("/public/css/fonts/SourceSansPro-It/SourceSansPro-It.ttf") format("truetype"), 38 | url("/public/css/fonts/SourceSansPro-It/SourceSansPro-It.eot") format("embedded-opentype"); 39 | font-style: italic; 40 | font-display: swap; 41 | } 42 | 43 | @font-face { 44 | font-family: "vspd"; 45 | src: 46 | url("/public/css/fonts/SourceSansPro-SemiboldIt/SourceSansPro-SemiboldIt.ttf.woff2") format("woff2"), 47 | url("/public/css/fonts/SourceSansPro-SemiboldIt/SourceSansPro-SemiboldIt.ttf.woff") format("woff"), 48 | url("/public/css/fonts/SourceSansPro-SemiboldIt/SourceSansPro-SemiboldIt.ttf") format("truetype"), 49 | url("/public/css/fonts/SourceSansPro-SemiboldIt/SourceSansPro-SemiboldIt.eot") format("embedded-opentype"); 50 | font-style: italic; 51 | font-weight: bold; 52 | font-display: swap; 53 | } 54 | 55 | html, body { 56 | font-family: "vspd", "Verdana", sans-serif; 57 | } 58 | 59 | pre, code, .code { 60 | font-family: "vspd-code", "Courier New", monospace; 61 | } 62 | -------------------------------------------------------------------------------- /database/upgrade_v3.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021-2024 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package database 6 | 7 | import ( 8 | "encoding/json" 9 | "fmt" 10 | 11 | "github.com/decred/slog" 12 | bolt "go.etcd.io/bbolt" 13 | ) 14 | 15 | func ticketBucketUpgrade(db *bolt.DB, log slog.Logger) error { 16 | log.Infof("Upgrading database to version %d", ticketBucketVersion) 17 | 18 | // Run the upgrade in a single database transaction so it can be safely 19 | // rolled back if an error is encountered. 20 | err := db.Update(func(tx *bolt.Tx) error { 21 | vspBkt := tx.Bucket(vspBktK) 22 | ticketBkt := vspBkt.Bucket(ticketBktK) 23 | 24 | // Count tickets so migration progress can be logged. 25 | todo := 0 26 | err := ticketBkt.ForEach(func(_, _ []byte) error { 27 | todo++ 28 | return nil 29 | }) 30 | if err != nil { 31 | return fmt.Errorf("could not count tickets: %w", err) 32 | } 33 | 34 | done := 0 35 | const batchSize = 2000 36 | err = ticketBkt.ForEach(func(k, v []byte) error { 37 | // Deserialize the old ticket. 38 | var ticket v1Ticket 39 | err := json.Unmarshal(v, &ticket) 40 | if err != nil { 41 | return fmt.Errorf("could not unmarshal ticket: %w", err) 42 | } 43 | 44 | // Delete the old ticket. 45 | err = ticketBkt.Delete(k) 46 | if err != nil { 47 | return fmt.Errorf("could not delete ticket: %w", err) 48 | } 49 | 50 | // Insert the new ticket. 51 | newBkt, err := ticketBkt.CreateBucket(k) 52 | if err != nil { 53 | return fmt.Errorf("could not create new ticket bucket: %w", err) 54 | } 55 | 56 | err = putTicketInBucket(newBkt, Ticket{ 57 | Hash: ticket.Hash, 58 | PurchaseHeight: ticket.PurchaseHeight, 59 | CommitmentAddress: ticket.CommitmentAddress, 60 | FeeAddressIndex: ticket.FeeAddressIndex, 61 | FeeAddress: ticket.FeeAddress, 62 | FeeAmount: ticket.FeeAmount, 63 | FeeExpiration: ticket.FeeExpiration, 64 | Confirmed: ticket.Confirmed, 65 | VotingWIF: ticket.VotingWIF, 66 | VoteChoices: ticket.VoteChoices, 67 | FeeTxHex: ticket.FeeTxHex, 68 | FeeTxHash: ticket.FeeTxHash, 69 | FeeTxStatus: ticket.FeeTxStatus, 70 | Outcome: ticket.Outcome, 71 | }) 72 | if err != nil { 73 | return fmt.Errorf("could not put new ticket in bucket: %w", err) 74 | } 75 | 76 | done++ 77 | 78 | if done%batchSize == 0 { 79 | log.Infof("Migrated %d/%d tickets", done, todo) 80 | } 81 | 82 | return nil 83 | }) 84 | if err != nil { 85 | return err 86 | } 87 | 88 | if done > 0 && done%batchSize != 0 { 89 | log.Infof("Migrated %d/%d tickets", done, todo) 90 | } 91 | 92 | // Update database version. 93 | err = vspBkt.Put(versionK, uint32ToBytes(ticketBucketVersion)) 94 | if err != nil { 95 | return fmt.Errorf("failed to update db version: %w", err) 96 | } 97 | 98 | return nil 99 | }) 100 | if err != nil { 101 | return err 102 | } 103 | 104 | log.Info("Upgrade completed") 105 | return nil 106 | } 107 | -------------------------------------------------------------------------------- /client/go.sum: -------------------------------------------------------------------------------- 1 | github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412 h1:w1UutsfOrms1J05zt7ISrnJIXKzwaspym5BTKGx93EI= 2 | github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412/go.mod h1:WPjqKcmVOxf0XSf3YxCJs6N6AOSrOx3obionmG7T0y0= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA= 5 | github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc= 6 | github.com/decred/base58 v1.0.5 h1:hwcieUM3pfPnE/6p3J100zoRfGkQxBulZHo7GZfOqic= 7 | github.com/decred/base58 v1.0.5/go.mod h1:s/8lukEHFA6bUQQb/v3rjUySJ2hu+RioCzLukAVkrfw= 8 | github.com/decred/dcrd/chaincfg/chainhash v1.0.4 h1:zRCv6tdncLfLTKYqu7hrXvs7hW+8FO/NvwoFvGsrluU= 9 | github.com/decred/dcrd/chaincfg/chainhash v1.0.4/go.mod h1:hA86XxlBWwHivMvxzXTSD0ZCG/LoYsFdWnCekkTMCqY= 10 | github.com/decred/dcrd/chaincfg/v3 v3.2.1 h1:x9zKJaU24WAKbxAR1UyFKHlM3oJgP0H9LodokM4X5lM= 11 | github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5ilcvdfma9wOH6Y= 12 | github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= 13 | github.com/decred/dcrd/crypto/ripemd160 v1.0.2 h1:TvGTmUBHDU75OHro9ojPLK+Yv7gDl2hnUvRocRCjsys= 14 | github.com/decred/dcrd/crypto/ripemd160 v1.0.2/go.mod h1:uGfjDyePSpa75cSQLzNdVmWlbQMBuiJkvXw/MNKRY4M= 15 | github.com/decred/dcrd/dcrec v1.0.1 h1:gDzlndw0zYxM5BlaV17d7ZJV6vhRe9njPBFeg4Db2UY= 16 | github.com/decred/dcrd/dcrec v1.0.1/go.mod h1:CO+EJd8eHFb8WHa84C7ZBkXsNUIywaTHb+UAuI5uo6o= 17 | github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3 h1:l/lhv2aJCUignzls81+wvga0TFlyoZx8QxRMQgXpZik= 18 | github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3/go.mod h1:AKpV6+wZ2MfPRJnTbQ6NPgWrKzbe9RCIlCF/FKzMtM8= 19 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg= 20 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= 21 | github.com/decred/dcrd/txscript/v4 v4.1.1 h1:R4M2+jMujgQA91899SkL0cW66d6DC76Gx+1W1oEHjc0= 22 | github.com/decred/dcrd/txscript/v4 v4.1.1/go.mod h1:7ybmJoI+b6dxvQ+0aXdZpkyrj0PbnylJCzFxD1g8+/A= 23 | github.com/decred/dcrd/wire v1.7.0 h1:5JHiDjEQeS4XUl4PfnTZYLwAD/E/+LwBmPRec/fP76o= 24 | github.com/decred/dcrd/wire v1.7.0/go.mod h1:lAqrzV0SU4kyV6INLEJgDtUjJaTaVKrbF4LHtaYl+zU= 25 | github.com/decred/slog v1.2.0 h1:soHAxV52B54Di3WtKLfPum9OFfWqwtf/ygf9njdfnPM= 26 | github.com/decred/slog v1.2.0/go.mod h1:kVXlGnt6DHy2fV5OjSeuvCJ0OmlmTF6LFpEPMu/fOY0= 27 | github.com/decred/vspd/types/v3 v3.0.0 h1:jHlQIpp6aCjIcFs8WE3AaVCJe1kgepNTq+nkBKAyQxk= 28 | github.com/decred/vspd/types/v3 v3.0.0/go.mod h1:hwifRZu6tpkbhSg2jZCUwuPaO/oETgbSCWCYJd4XepY= 29 | github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM= 30 | github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 31 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 32 | golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= 33 | golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 34 | lukechampine.com/blake3 v1.3.0 h1:sJ3XhFINmHSrYCgl958hscfIa3bw8x4DqMP3u1YvoYE= 35 | lukechampine.com/blake3 v1.3.0/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= 36 | -------------------------------------------------------------------------------- /types/errors_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package types 6 | 7 | import ( 8 | "net/http" 9 | "testing" 10 | ) 11 | 12 | // TestErrorDefaultMessages ensures each ErrorKind can be mapped to a default 13 | // descriptive error message. 14 | func TestErrorDefaultMessages(t *testing.T) { 15 | tests := []struct { 16 | in ErrorCode 17 | wantMsg string 18 | }{ 19 | {ErrBadRequest, "bad request"}, 20 | {ErrInternalError, "internal error"}, 21 | {ErrVspClosed, "vsp is closed"}, 22 | {ErrFeeAlreadyReceived, "fee tx already received for ticket"}, 23 | {ErrInvalidFeeTx, "invalid fee tx"}, 24 | {ErrFeeTooSmall, "fee too small"}, 25 | {ErrUnknownTicket, "unknown ticket"}, 26 | {ErrTicketCannotVote, "ticket not eligible to vote"}, 27 | {ErrFeeExpired, "fee has expired"}, 28 | {ErrInvalidVoteChoices, "invalid vote choices"}, 29 | {ErrBadSignature, "bad request signature"}, 30 | {ErrInvalidPrivKey, "invalid private key"}, 31 | {ErrFeeNotReceived, "no fee tx received for ticket"}, 32 | {ErrInvalidTicket, "not a valid ticket tx"}, 33 | {ErrCannotBroadcastTicket, "ticket transaction could not be broadcast"}, 34 | {ErrCannotBroadcastFee, "fee transaction could not be broadcast"}, 35 | {ErrCannotBroadcastFeeUnknownOutputs, "fee transaction could not be broadcast due to unknown outputs"}, 36 | {ErrInvalidTimestamp, "old or reused timestamp"}, 37 | {ErrorCode(9999), "unknown error"}, 38 | } 39 | 40 | for _, test := range tests { 41 | actualMsg := test.in.DefaultMessage() 42 | if actualMsg != test.wantMsg { 43 | t.Errorf("wrong default message for ErrorKind(%d). expected: %q actual: %q ", 44 | test.in, test.wantMsg, actualMsg) 45 | continue 46 | } 47 | } 48 | } 49 | 50 | // TestErrorHTTPStatus ensures each ErrorCode can be mapped to a corresponding HTTP status code. 51 | func TestErrorHTTPStatus(t *testing.T) { 52 | tests := []struct { 53 | in ErrorCode 54 | wantStatus int 55 | }{ 56 | {ErrBadRequest, http.StatusBadRequest}, 57 | {ErrInternalError, http.StatusInternalServerError}, 58 | {ErrVspClosed, http.StatusBadRequest}, 59 | {ErrFeeAlreadyReceived, http.StatusBadRequest}, 60 | {ErrInvalidFeeTx, http.StatusBadRequest}, 61 | {ErrFeeTooSmall, http.StatusBadRequest}, 62 | {ErrUnknownTicket, http.StatusBadRequest}, 63 | {ErrTicketCannotVote, http.StatusBadRequest}, 64 | {ErrFeeExpired, http.StatusBadRequest}, 65 | {ErrInvalidVoteChoices, http.StatusBadRequest}, 66 | {ErrBadSignature, http.StatusBadRequest}, 67 | {ErrInvalidPrivKey, http.StatusBadRequest}, 68 | {ErrFeeNotReceived, http.StatusBadRequest}, 69 | {ErrInvalidTicket, http.StatusBadRequest}, 70 | {ErrCannotBroadcastTicket, http.StatusInternalServerError}, 71 | {ErrCannotBroadcastFee, http.StatusInternalServerError}, 72 | {ErrCannotBroadcastFeeUnknownOutputs, http.StatusPreconditionRequired}, 73 | {ErrInvalidTimestamp, http.StatusBadRequest}, 74 | {ErrorCode(9999), http.StatusInternalServerError}, 75 | } 76 | 77 | for _, test := range tests { 78 | result := test.in.HTTPStatus() 79 | if result != test.wantStatus { 80 | t.Errorf("wrong HTTP status for ErrorKind(%d). expected: %d actual: %d ", 81 | test.in, test.wantStatus, result) 82 | continue 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /docs/release-notes/release-notes.1.2.0.md: -------------------------------------------------------------------------------- 1 | # vspd 1.2.0 2 | 3 | vspd v1.2.0 contains all development work completed since v1.1.0 (March 2022). 4 | Since then, 80 commits have been produced and merged by 3 contributors. 5 | All commits included in this release can be viewed on GitHub 6 | [here](https://github.com/decred/vspd/compare/release-v1.1.0...release-v1.2.0). 7 | 8 | ## Dependencies 9 | 10 | vspd 1.2.0 must be built with go 1.19 or later, and requires: 11 | 12 | - dcrd 1.8.0 13 | - dcrwallet 1.8.0 14 | 15 | When deploying vspd to production, always use release versions of all binaries. 16 | Neither vspd nor its dependencies should be built from master when handling 17 | mainnet tickets. 18 | 19 | ## Recommended Upgrade Path 20 | 21 | The upgrade path below includes vspd downtime, during which clients will not be 22 | able to register new tickets, check their ticket status, or update their voting 23 | preferences. Voting on tickets already registered with the VSP will not be 24 | interrupted. You may wish to put up a temporary maintenance webpage or announce 25 | downtime in public channels. 26 | 27 | 1. Build vspd from the 1.2.0 release tag. Build dcrwallet and dcrd from their 28 | 1.8.0 release tags. 29 | 1. Stop vspd. 30 | 1. Stop the instance of dcrd running on the vspd server. 31 | 1. **Make a backup of the vspd database file in case rollback is required.** 32 | 1. Install new dcrd binary on the vspd server and start it to begin database 33 | upgrades. You can proceed with the following steps while the upgrades run. 34 | 1. Upgrade voting wallets one at a time so at least two wallets remain online 35 | for voting. On each server: 36 | 1. Stop dcrwallet. 37 | 1. Stop dcrd. 38 | 1. Install new dcrd binary and start. 39 | 1. Wait for dcrd database upgrades to complete. 40 | 1. Check dcrd log file for warnings or errors. 41 | 1. Install new dcrwallet binary and start. 42 | 1. Wait for dcrwallet database upgrades to complete. 43 | 1. Check dcrwallet log file for warnings or errors. 44 | 1. Wait for dcrd on the vspd server to complete its database upgrade. 45 | 1. Check dcrd log file for warnings or errors. 46 | 1. Install new vspd binary and start it. 47 | 1. Check vspd log file for warnings or errors. 48 | 1. Log in to the admin webpage and check the VSP Status tab for any issues. 49 | 50 | ## Notable Changes 51 | 52 | - vspd has moved into the cmd directory of the repo. This is standard practise 53 | in Decred repos which include more than one golang executable. The new command 54 | to build is: 55 | 56 | ```no-highlight 57 | go build ./cmd/vspd 58 | ``` 59 | 60 | - `/vspinfo` now returns `votingwalletsonline` and `votingwalletstotal`. 61 | - The login form for the admin page is now limited to 3 attempts per second per IP. 62 | - A warning is logged when running a pre-release version of vspd on mainnet. 63 | - The ID of the git commit vspd was built from is now included in startup 64 | logging and in the output of `--version`. 65 | - A new executable `vote-validator` is a tool for VSP admins to verify that 66 | their vspd deployment is voting correctly according to user preferences. 67 | Further details can be found in the [README](./cmd/vote-validator). 68 | - A new executable `v3tool` is a development tool helpful for testing changes to 69 | vspd. Further details can be found in the [README](./cmd/v3tool). 70 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/decred/vspd 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | decred.org/dcrwallet/v5 v5.0.1 7 | github.com/decred/dcrd/blockchain/stake/v5 v5.0.2 8 | github.com/decred/dcrd/blockchain/standalone/v2 v2.2.2 9 | github.com/decred/dcrd/chaincfg/chainhash v1.0.5 10 | github.com/decred/dcrd/chaincfg/v3 v3.3.0 11 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 12 | github.com/decred/dcrd/dcrutil/v4 v4.0.3 13 | github.com/decred/dcrd/gcs/v4 v4.1.1 14 | github.com/decred/dcrd/hdkeychain/v3 v3.1.3 15 | github.com/decred/dcrd/rpc/jsonrpc/types/v4 v4.4.0 16 | github.com/decred/dcrd/txscript/v4 v4.1.2 17 | github.com/decred/dcrd/wire v1.7.1 18 | github.com/decred/slog v1.2.0 19 | github.com/decred/vspd/client/v4 v4.0.2 20 | github.com/decred/vspd/types/v3 v3.0.0 21 | github.com/dustin/go-humanize v1.0.1 22 | github.com/gin-gonic/gin v1.10.1 23 | github.com/gorilla/sessions v1.4.0 24 | github.com/jessevdk/go-flags v1.6.1 25 | github.com/jrick/bitset v1.0.0 26 | github.com/jrick/logrotate v1.1.2 27 | github.com/jrick/wsrpc/v2 v2.4.0 28 | go.etcd.io/bbolt v1.4.3 29 | ) 30 | 31 | require ( 32 | github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412 // indirect 33 | github.com/bytedance/sonic v1.11.6 // indirect 34 | github.com/bytedance/sonic/loader v0.1.1 // indirect 35 | github.com/cloudwego/base64x v0.1.4 // indirect 36 | github.com/cloudwego/iasm v0.2.0 // indirect 37 | github.com/dchest/siphash v1.2.3 // indirect 38 | github.com/decred/base58 v1.0.6 // indirect 39 | github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect 40 | github.com/decred/dcrd/crypto/rand v1.0.1 // indirect 41 | github.com/decred/dcrd/crypto/ripemd160 v1.0.2 // indirect 42 | github.com/decred/dcrd/database/v3 v3.0.3 // indirect 43 | github.com/decred/dcrd/dcrec v1.0.1 // indirect 44 | github.com/decred/dcrd/dcrec/edwards/v2 v2.0.4 // indirect 45 | github.com/decred/dcrd/dcrjson/v4 v4.2.0 // indirect 46 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect 47 | github.com/gin-contrib/sse v0.1.0 // indirect 48 | github.com/go-playground/locales v0.14.1 // indirect 49 | github.com/go-playground/universal-translator v0.18.1 // indirect 50 | github.com/go-playground/validator/v10 v10.20.0 // indirect 51 | github.com/goccy/go-json v0.10.2 // indirect 52 | github.com/gorilla/securecookie v1.1.2 // indirect 53 | github.com/gorilla/websocket v1.5.1 // indirect 54 | github.com/json-iterator/go v1.1.12 // indirect 55 | github.com/klauspost/cpuid/v2 v2.2.8 // indirect 56 | github.com/kr/text v0.2.0 // indirect 57 | github.com/leodido/go-urn v1.4.0 // indirect 58 | github.com/mattn/go-isatty v0.0.20 // indirect 59 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 60 | github.com/modern-go/reflect2 v1.0.2 // indirect 61 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect 62 | github.com/pelletier/go-toml/v2 v2.2.2 // indirect 63 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 64 | github.com/ugorji/go/codec v1.2.12 // indirect 65 | golang.org/x/arch v0.8.0 // indirect 66 | golang.org/x/crypto v0.45.0 // indirect 67 | golang.org/x/net v0.47.0 // indirect 68 | golang.org/x/sys v0.38.0 // indirect 69 | golang.org/x/text v0.31.0 // indirect 70 | golang.org/x/time v0.3.0 71 | google.golang.org/protobuf v1.36.10 // indirect 72 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect 73 | gopkg.in/yaml.v3 v3.0.1 // indirect 74 | lukechampine.com/blake3 v1.3.0 // indirect 75 | ) 76 | -------------------------------------------------------------------------------- /internal/version/version.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020-2025 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package version 6 | 7 | import ( 8 | "bytes" 9 | "fmt" 10 | "runtime/debug" 11 | "strings" 12 | ) 13 | 14 | // semverAlphabet is an alphabet of all characters allowed in semver prerelease 15 | // identifiers, and the . separator. 16 | const semverAlphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-." 17 | 18 | // Constants defining the application version number. 19 | const ( 20 | major = 1 21 | minor = 5 22 | patch = 0 23 | ) 24 | 25 | // preRelease contains the prerelease name of the application. It is a variable 26 | // so it can be modified at link time (e.g. 27 | // `-ldflags "-X decred.org/vspd/version.preRelease=rc1"`). 28 | // It must only contain characters from the semantic version alphabet. 29 | var preRelease = "pre" 30 | 31 | func IsPreRelease() bool { 32 | return preRelease != "" 33 | } 34 | 35 | // String returns the application version as a properly formed string per the 36 | // semantic versioning 2.0.0 spec (https://semver.org/). 37 | func String() string { 38 | // Start with the major, minor, and path versions. 39 | version := fmt.Sprintf("%d.%d.%d", major, minor, patch) 40 | 41 | // Append pre-release version if there is one. The hyphen called for 42 | // by the semantic versioning spec is automatically appended and should 43 | // not be contained in the pre-release string. The pre-release version 44 | // is not appended if it contains invalid characters. 45 | preRelease := normalizeVerString(preRelease) 46 | if preRelease != "" { 47 | version = version + "-" + preRelease 48 | } 49 | 50 | buildMetadata := vcsCommitID() 51 | 52 | // Append build metadata if there is any. The plus called for 53 | // by the semantic versioning spec is automatically appended and should 54 | // not be contained in the build metadata string. The build metadata 55 | // string is not appended if it contains invalid characters. 56 | buildMetadata = normalizeVerString(buildMetadata) 57 | if buildMetadata != "" { 58 | version = version + "+" + buildMetadata 59 | } 60 | 61 | return version 62 | } 63 | 64 | func vcsCommitID() string { 65 | bi, ok := debug.ReadBuildInfo() 66 | if !ok { 67 | return "" 68 | } 69 | var vcs, revision string 70 | for _, bs := range bi.Settings { 71 | switch bs.Key { 72 | case "vcs": 73 | vcs = bs.Value 74 | case "vcs.revision": 75 | revision = bs.Value 76 | } 77 | } 78 | if vcs == "" { 79 | return "" 80 | } 81 | if vcs == "git" && len(revision) > 9 { 82 | revision = revision[:9] 83 | } 84 | return revision 85 | } 86 | 87 | // normalizeVerString returns the passed string stripped of all characters which 88 | // are not valid according to the semantic versioning guidelines for pre-release 89 | // version and build metadata strings. In particular they MUST only contain 90 | // characters in semanticAlphabet. 91 | func normalizeVerString(str string) string { 92 | var buf bytes.Buffer 93 | for _, r := range str { 94 | if strings.ContainsRune(semverAlphabet, r) { 95 | _, err := buf.WriteRune(r) 96 | // Writing to a bytes.Buffer panics on OOM, and all 97 | // errors are unexpected. 98 | if err != nil { 99 | panic(err) 100 | } 101 | } 102 | } 103 | return buf.String() 104 | } 105 | -------------------------------------------------------------------------------- /docs/release-notes/release-notes.1.3.0.md: -------------------------------------------------------------------------------- 1 | # vspd 1.3.0 2 | 3 | vspd v1.3.0 contains all development work completed since v1.2.0 (June 2023). 4 | All commits included in this release can be viewed 5 | [on GitHub](https://github.com/decred/vspd/compare/release-v1.2.0...release-v1.3.0). 6 | 7 | ## Dependencies 8 | 9 | vspd 1.3.0 must be built with go 1.20 or later, and requires: 10 | 11 | - dcrd 1.8.0 12 | - dcrwallet 1.8.0 13 | 14 | When deploying vspd to production, always use release versions of all binaries. 15 | Neither vspd nor its dependencies should be built from master when handling 16 | mainnet tickets. 17 | 18 | ## Recommended Upgrade Path 19 | 20 | The upgrade path below includes vspd downtime, during which clients will not be 21 | able to register new tickets, check their ticket status, or update their voting 22 | preferences. Voting on tickets already registered with the VSP will not be 23 | interrupted. You may wish to put up a temporary maintenance webpage or announce 24 | downtime in public channels. 25 | 26 | The upgrade path assumes dcrd and dcrwallet are already version 1.8.0. 27 | 28 | 1. Build vspd from the 1.3.0 release tag. 29 | 1. Stop vspd. 30 | 1. **Make a backup of the vspd database file in case rollback is required.** 31 | 1. Install new vspd binary and start it. 32 | 1. Check vspd log file for warnings or errors. 33 | 1. Log in to the admin webpage and check the VSP Status tab for any issues. 34 | 35 | ## Notable Changes 36 | 37 | - Fee calculation now takes the new block reward subsidy split from the activation 38 | of [DCP-0012](https://github.com/decred/dcps/blob/master/dcp-0012/dcp-0012.mediawiki) 39 | into consideration. In practice, this means that VSPs will begin charging 40 | marginally higher fees. 41 | - vspd will now distinguish between tickets which are "missed" and tickets which 42 | are "expired". Previously these tickets would be considered as a single set 43 | labelled "expired". This is acheived using dcrd and 44 | [Golomb-Coded Set filters](https://github.com/decred/dcrd/tree/master/gcs#gcs). 45 | - vspd 1.3.0 will perform a **one-time update of every revoked ticket in the 46 | database** the first time it is started. This may take a while for VSPs which 47 | have been active for a long time or have a large number of revoked tickets. 48 | - Expired and missed tickets added to `/vspinfo`. Revoked is now deprecated. 49 | - Minor improvements to web UI: 50 | - Homepage now displays expired and missed tickets instead of revoked tickets. 51 | - Homepage only displays the current network if it is not running on mainnet. 52 | - Admin page now displays decoded transactions in a human readable format 53 | in addition to the raw bytes. 54 | - Logging has been reviewed to reduce unnecessary verbosity and to make better 55 | use of log levels. Scripts or monitoring solutions which parse logs may need 56 | to be updated. 57 | 58 | ### Bug Fixes 59 | 60 | - Disable spell checking on admin page input for ticket hash 61 | ([#397](https://github.com/decred/vspd/pull/397)). 62 | - Duplicate transactions will no longer cause an error when calling 63 | sendrawtransaction 64 | ([#398](https://github.com/decred/vspd/pull/398)). 65 | - Calculate missed/expired/revoked ticket ratios as percentage of all tickets, 66 | not just voted tickets 67 | ([#417](https://github.com/decred/vspd/pull/417)). 68 | - Web server returns explicit errors intead of zero values when cache is not ready 69 | ([#440](https://github.com/decred/vspd/pull/440)). 70 | -------------------------------------------------------------------------------- /internal/webapi/binding_test.go: -------------------------------------------------------------------------------- 1 | package webapi 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/decred/vspd/types/v3" 7 | "github.com/gin-gonic/gin/binding" 8 | ) 9 | 10 | // TestGinJSONBinding does not test code in this package. It is a 11 | // characterization test to determine exactly how Gin handles JSON binding tags. 12 | func TestGinJSONBinding(t *testing.T) { 13 | tests := map[string]struct { 14 | req []byte 15 | expectedErr string 16 | }{ 17 | 18 | "Filled arrays bind without error": { 19 | req: []byte(`{ 20 | "timestamp": 12345, 21 | "tickethash": "hash", 22 | "votechoices": {"k": "v"}, 23 | "tspendpolicy": {"k": "v"}, 24 | "treasurypolicy": {"k": "v"} 25 | }`), 26 | expectedErr: "", 27 | }, 28 | 29 | "Array filled beyond max does not bind": { 30 | req: []byte(`{ 31 | "timestamp": 12345, 32 | "tickethash": "hash", 33 | "votechoices": {"k": "v"}, 34 | "tspendpolicy": {"k": "v"}, 35 | "treasurypolicy": {"k1": "v","k2": "v","k3": "v","k4": "v"} 36 | }`), 37 | expectedErr: "Key: 'SetVoteChoicesRequest.TreasuryPolicy' Error:Field validation for 'TreasuryPolicy' failed on the 'max' tag", 38 | }, 39 | 40 | "Empty arrays bind without error": { 41 | req: []byte(`{ 42 | "timestamp": 12345, 43 | "tickethash": "hash", 44 | "votechoices": {}, 45 | "tspendpolicy": {}, 46 | "treasurypolicy": {} 47 | }`), 48 | expectedErr: "", 49 | }, 50 | 51 | "Missing array with 'required' tag does not bind": { 52 | req: []byte(`{ 53 | "timestamp": 12345, 54 | "tickethash": "hash", 55 | "tspendpolicy": {}, 56 | "treasurypolicy": {} 57 | }`), 58 | expectedErr: "Key: 'SetVoteChoicesRequest.VoteChoices' Error:Field validation for 'VoteChoices' failed on the 'required' tag", 59 | }, 60 | 61 | "Missing array with 'max' tag binds without error": { 62 | req: []byte(`{ 63 | "timestamp": 12345, 64 | "tickethash": "hash", 65 | "votechoices": {}, 66 | "treasurypolicy": {} 67 | }`), 68 | expectedErr: "", 69 | }, 70 | 71 | "Null array with 'required' tag does not bind": { 72 | req: []byte(`{ 73 | "timestamp": 12345, 74 | "tickethash": "hash", 75 | "votechoices": null, 76 | "tspendpolicy": {}, 77 | "treasurypolicy": {} 78 | }`), 79 | expectedErr: "Key: 'SetVoteChoicesRequest.VoteChoices' Error:Field validation for 'VoteChoices' failed on the 'required' tag", 80 | }, 81 | 82 | "Null array with 'max' tag binds without error": { 83 | req: []byte(`{ 84 | "timestamp": 12345, 85 | "tickethash": "hash", 86 | "votechoices": {}, 87 | "tspendpolicy": null, 88 | "treasurypolicy": {} 89 | }`), 90 | expectedErr: "", 91 | }, 92 | } 93 | 94 | for testName, test := range tests { 95 | t.Run(testName, func(t *testing.T) { 96 | err := binding.JSON.BindBody(test.req, &types.SetVoteChoicesRequest{}) 97 | if test.expectedErr == "" { 98 | if err != nil { 99 | t.Fatalf("unexpected error: %v", err) 100 | } 101 | } else { 102 | if err == nil { 103 | t.Fatalf("expected error but got none") 104 | } 105 | if err.Error() != test.expectedErr { 106 | t.Fatalf("incorrect error, got %q expected %q", 107 | err.Error(), test.expectedErr) 108 | } 109 | } 110 | 111 | }) 112 | } 113 | 114 | } 115 | -------------------------------------------------------------------------------- /rpc/client.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020-2022 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package rpc 6 | 7 | import ( 8 | "context" 9 | "crypto/tls" 10 | "crypto/x509" 11 | "sync" 12 | 13 | "github.com/decred/slog" 14 | "github.com/jrick/wsrpc/v2" 15 | ) 16 | 17 | // Caller provides a client interface to perform JSON-RPC remote procedure calls. 18 | type Caller interface { 19 | // String returns the dialed URL. 20 | String() string 21 | 22 | // Call performs the remote procedure call defined by method and 23 | // waits for a response or a broken client connection. 24 | // Args provides positional parameters for the call. 25 | // Res must be a pointer to a struct, slice, or map type to unmarshal 26 | // a result (if any), or nil if no result is needed. 27 | Call(ctx context.Context, method string, res any, args ...any) error 28 | } 29 | 30 | // client wraps a wsrpc.Client, as well as all of the connection details 31 | // required to make a new client if the existing client is closed. 32 | type client struct { 33 | mu *sync.Mutex 34 | client *wsrpc.Client 35 | addr string 36 | tlsOpt wsrpc.Option 37 | authOpt wsrpc.Option 38 | notifier wsrpc.Notifier 39 | log slog.Logger 40 | } 41 | 42 | func setup(user, pass, addr string, cert []byte, log slog.Logger) *client { 43 | 44 | // Create TLS options. 45 | pool := x509.NewCertPool() 46 | pool.AppendCertsFromPEM(cert) 47 | tc := &tls.Config{ 48 | MinVersion: tls.VersionTLS12, 49 | CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256}, 50 | CipherSuites: []uint16{ // Only applies to TLS 1.2. TLS 1.3 ciphersuites are not configurable. 51 | tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, 52 | tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, 53 | tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, 54 | tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, 55 | tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, 56 | tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, 57 | }, 58 | RootCAs: pool, 59 | } 60 | tlsOpt := wsrpc.WithTLSConfig(tc) 61 | 62 | // Create authentication options. 63 | authOpt := wsrpc.WithBasicAuth(user, pass) 64 | 65 | var mu sync.Mutex 66 | var c *wsrpc.Client 67 | fullAddr := "wss://" + addr + "/ws" 68 | return &client{&mu, c, fullAddr, tlsOpt, authOpt, nil, log} 69 | } 70 | 71 | func (c *client) Close() { 72 | if c.client != nil { 73 | select { 74 | case <-c.client.Done(): 75 | c.log.Tracef("RPC already closed (%s)", c.addr) 76 | 77 | default: 78 | if err := c.client.Close(); err != nil { 79 | c.log.Errorf("Failed to close RPC (%s): %v", c.addr, err) 80 | } else { 81 | c.log.Tracef("RPC closed (%s)", c.addr) 82 | } 83 | } 84 | } 85 | } 86 | 87 | // dial will return a connect rpc client if one exists, or attempt to create a 88 | // new one if not. A boolean indicates whether this connection is new (true), or 89 | // if it is an existing connection which is being reused (false). 90 | func (c *client) dial(ctx context.Context) (Caller, bool, error) { 91 | c.mu.Lock() 92 | defer c.mu.Unlock() 93 | 94 | if c.client != nil { 95 | select { 96 | case <-c.client.Done(): 97 | c.log.Debugf("RPC client %s errored (%v); reconnecting...", c.addr, c.client.Err()) 98 | c.client = nil 99 | default: 100 | return c.client, false, nil 101 | } 102 | } 103 | 104 | var err error 105 | c.client, err = wsrpc.Dial(ctx, c.addr, c.tlsOpt, c.authOpt, wsrpc.WithNotifier(c.notifier)) 106 | if err != nil { 107 | return nil, false, err 108 | } 109 | return c.client, true, nil 110 | } 111 | -------------------------------------------------------------------------------- /database/upgrades.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021-2024 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package database 6 | 7 | import ( 8 | "fmt" 9 | 10 | "github.com/decred/slog" 11 | bolt "go.etcd.io/bbolt" 12 | ) 13 | 14 | const ( 15 | // initialVersion is the version of a freshly created database which has had 16 | // no upgrades applied. 17 | initialVersion = 1 18 | 19 | // removeOldFeeTxVersion deletes any raw fee transactions which remain in 20 | // the database after already having been confirmed on-chain. There is no 21 | // need to keep these, and they take up a lot of space. 22 | removeOldFeeTxVersion = 2 23 | 24 | // ticketBucketVersion changes the way tickets are stored. Previously they 25 | // were stored as JSON encoded strings in a single bucket. This upgrade 26 | // moves each ticket into its own bucket and does away with JSON encoding. 27 | ticketBucketVersion = 3 28 | 29 | // altSignAddrVersion adds a bucket to store alternate sign addresses used 30 | // to verify messages sent to the vspd. 31 | altSignAddrVersion = 4 32 | 33 | // xPubBucketVersion changes how the xpub key and its associated addr index 34 | // are stored. Previously only a single key was supported because it was 35 | // stored as a single value in the root bucket. Now a dedicated bucket which 36 | // can hold multiple keys is used, enabling support for historic retired 37 | // keys as well as the current key. 38 | xPubBucketVersion = 5 39 | 40 | // latestVersion is the latest version of the database that is understood by 41 | // vspd. Databases with recorded versions higher than this will fail to open 42 | // (meaning any upgrades prevent reverting to older software). 43 | latestVersion = xPubBucketVersion 44 | ) 45 | 46 | // upgrades maps between old database versions and the upgrade function to 47 | // upgrade the database to the next version. 48 | var upgrades = []func(tx *bolt.DB, log slog.Logger) error{ 49 | initialVersion: removeOldFeeTxUpgrade, 50 | removeOldFeeTxVersion: ticketBucketUpgrade, 51 | ticketBucketVersion: altSignAddrUpgrade, 52 | altSignAddrVersion: xPubBucketUpgrade, 53 | } 54 | 55 | // v1Ticket has the json tags required to unmarshal tickets stored in the 56 | // v1 database format. 57 | type v1Ticket struct { 58 | Hash string `json:"hsh"` 59 | PurchaseHeight int64 `json:"phgt"` 60 | CommitmentAddress string `json:"cmtaddr"` 61 | FeeAddressIndex uint32 `json:"faddridx"` 62 | FeeAddress string `json:"faddr"` 63 | FeeAmount int64 `json:"famt"` 64 | FeeExpiration int64 `json:"fexp"` 65 | Confirmed bool `json:"conf"` 66 | VotingWIF string `json:"vwif"` 67 | VoteChoices map[string]string `json:"vchces"` 68 | FeeTxHex string `json:"fhex"` 69 | FeeTxHash string `json:"fhsh"` 70 | FeeTxStatus FeeStatus `json:"fsts"` 71 | Outcome TicketOutcome `json:"otcme"` 72 | } 73 | 74 | // Upgrade will update the database to the latest known version. 75 | func (vdb *VspDatabase) Upgrade(currentVersion uint32) error { 76 | if currentVersion == latestVersion { 77 | // No upgrades required. 78 | return nil 79 | } 80 | 81 | if currentVersion > latestVersion { 82 | // Database is too new. 83 | return fmt.Errorf("expected database version <= %d, got %d", 84 | latestVersion, currentVersion) 85 | } 86 | 87 | // Execute all necessary upgrades in order. 88 | for _, upgrade := range upgrades[currentVersion:] { 89 | err := upgrade(vdb.db, vdb.log) 90 | if err != nil { 91 | return err 92 | } 93 | } 94 | 95 | return nil 96 | } 97 | -------------------------------------------------------------------------------- /internal/vspd/vspd.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020-2023 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package vspd 6 | 7 | import ( 8 | "context" 9 | "errors" 10 | "time" 11 | 12 | "github.com/decred/dcrd/wire" 13 | "github.com/decred/slog" 14 | "github.com/decred/vspd/database" 15 | "github.com/decred/vspd/internal/config" 16 | "github.com/decred/vspd/rpc" 17 | ) 18 | 19 | const ( 20 | // requiredConfs is the number of confirmations required to consider a 21 | // ticket purchase or a fee transaction to be final. 22 | requiredConfs = 6 23 | 24 | // consistencyInterval is the time period between wallet consistency checks. 25 | consistencyInterval = 30 * time.Minute 26 | 27 | // dcrdInterval is the time period between dcrd connection checks. 28 | dcrdInterval = time.Second * 15 29 | ) 30 | 31 | type Vspd struct { 32 | network *config.Network 33 | log slog.Logger 34 | db *database.VspDatabase 35 | dcrd rpc.DcrdConnect 36 | wallets rpc.WalletConnect 37 | 38 | blockNotifChan chan *wire.BlockHeader 39 | 40 | // lastScannedBlock is the height of the most recent block which has been 41 | // scanned for spent tickets. 42 | lastScannedBlock int64 43 | } 44 | 45 | func New(network *config.Network, log slog.Logger, db *database.VspDatabase, 46 | dcrd rpc.DcrdConnect, wallets rpc.WalletConnect, blockNotifChan chan *wire.BlockHeader) *Vspd { 47 | 48 | v := &Vspd{ 49 | network: network, 50 | log: log, 51 | db: db, 52 | dcrd: dcrd, 53 | wallets: wallets, 54 | 55 | blockNotifChan: blockNotifChan, 56 | } 57 | 58 | return v 59 | } 60 | 61 | func (v *Vspd) Run(ctx context.Context) { 62 | // Run database integrity checks to ensure all data in database is present 63 | // and up-to-date. 64 | err := v.checkDatabaseIntegrity(ctx) 65 | if err != nil { 66 | // Don't log error if shutdown was requested, just return. 67 | if errors.Is(err, context.Canceled) { 68 | return 69 | } 70 | 71 | // vspd should still start if this fails, so just log an error. 72 | v.log.Errorf("Database integrity check failed: %v", err) 73 | } 74 | 75 | // Stop if shutdown requested. 76 | if ctx.Err() != nil { 77 | return 78 | } 79 | 80 | // Run the update function now to catch up with any blocks mined while vspd 81 | // was shut down. 82 | v.update(ctx) 83 | 84 | // Stop if shutdown requested. 85 | if ctx.Err() != nil { 86 | return 87 | } 88 | 89 | // Run voting wallet consistency check now to ensure all wallets are up to 90 | // date. 91 | v.checkWalletConsistency(ctx) 92 | 93 | // Stop if shutdown requested. 94 | if ctx.Err() != nil { 95 | return 96 | } 97 | 98 | // Start all background tasks and notification handlers. 99 | consistencyTicker := time.NewTicker(consistencyInterval) 100 | defer consistencyTicker.Stop() 101 | dcrdTicker := time.NewTicker(dcrdInterval) 102 | defer dcrdTicker.Stop() 103 | 104 | for { 105 | select { 106 | // Run voting wallet consistency check periodically. 107 | case <-consistencyTicker.C: 108 | v.checkWalletConsistency(ctx) 109 | 110 | // Ensure dcrd client is connected so notifications are received. 111 | case <-dcrdTicker.C: 112 | _, _, err := v.dcrd.Client() 113 | if err != nil { 114 | v.log.Error(err) 115 | } 116 | 117 | // Run the update function every time a block connected notification is 118 | // received from dcrd. 119 | case header := <-v.blockNotifChan: 120 | v.log.Debugf("Block notification %d (%s)", header.Height, header.BlockHash().String()) 121 | v.update(ctx) 122 | 123 | // Handle shutdown request. 124 | case <-ctx.Done(): 125 | return 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /types/errors.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020-2022 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package types 6 | 7 | import "net/http" 8 | 9 | // ErrorCode is an integer which represents a kind of error which may be 10 | // encountered by vspd. 11 | type ErrorCode int64 12 | 13 | const ( 14 | ErrBadRequest ErrorCode = iota 15 | ErrInternalError 16 | ErrVspClosed 17 | ErrFeeAlreadyReceived 18 | ErrInvalidFeeTx 19 | ErrFeeTooSmall 20 | ErrUnknownTicket 21 | ErrTicketCannotVote 22 | ErrFeeExpired 23 | ErrInvalidVoteChoices 24 | ErrBadSignature 25 | ErrInvalidPrivKey 26 | ErrFeeNotReceived 27 | ErrInvalidTicket 28 | ErrCannotBroadcastTicket 29 | ErrCannotBroadcastFee 30 | ErrCannotBroadcastFeeUnknownOutputs 31 | ErrInvalidTimestamp 32 | ) 33 | 34 | // HTTPStatus returns a corresponding HTTP status code for a given error code. 35 | func (e ErrorCode) HTTPStatus() int { 36 | switch e { 37 | case ErrBadRequest: 38 | return http.StatusBadRequest 39 | case ErrInternalError: 40 | return http.StatusInternalServerError 41 | case ErrVspClosed: 42 | return http.StatusBadRequest 43 | case ErrFeeAlreadyReceived: 44 | return http.StatusBadRequest 45 | case ErrInvalidFeeTx: 46 | return http.StatusBadRequest 47 | case ErrFeeTooSmall: 48 | return http.StatusBadRequest 49 | case ErrUnknownTicket: 50 | return http.StatusBadRequest 51 | case ErrTicketCannotVote: 52 | return http.StatusBadRequest 53 | case ErrFeeExpired: 54 | return http.StatusBadRequest 55 | case ErrInvalidVoteChoices: 56 | return http.StatusBadRequest 57 | case ErrBadSignature: 58 | return http.StatusBadRequest 59 | case ErrInvalidPrivKey: 60 | return http.StatusBadRequest 61 | case ErrFeeNotReceived: 62 | return http.StatusBadRequest 63 | case ErrInvalidTicket: 64 | return http.StatusBadRequest 65 | case ErrCannotBroadcastTicket: 66 | return http.StatusInternalServerError 67 | case ErrCannotBroadcastFee: 68 | return http.StatusInternalServerError 69 | case ErrCannotBroadcastFeeUnknownOutputs: 70 | return http.StatusPreconditionRequired 71 | case ErrInvalidTimestamp: 72 | return http.StatusBadRequest 73 | default: 74 | return http.StatusInternalServerError 75 | } 76 | } 77 | 78 | // DefaultMessage returns a descriptive error string for a given error code. 79 | func (e ErrorCode) DefaultMessage() string { 80 | switch e { 81 | case ErrBadRequest: 82 | return "bad request" 83 | case ErrInternalError: 84 | return "internal error" 85 | case ErrVspClosed: 86 | return "vsp is closed" 87 | case ErrFeeAlreadyReceived: 88 | return "fee tx already received for ticket" 89 | case ErrInvalidFeeTx: 90 | return "invalid fee tx" 91 | case ErrFeeTooSmall: 92 | return "fee too small" 93 | case ErrUnknownTicket: 94 | return "unknown ticket" 95 | case ErrTicketCannotVote: 96 | return "ticket not eligible to vote" 97 | case ErrFeeExpired: 98 | return "fee has expired" 99 | case ErrInvalidVoteChoices: 100 | return "invalid vote choices" 101 | case ErrBadSignature: 102 | return "bad request signature" 103 | case ErrInvalidPrivKey: 104 | return "invalid private key" 105 | case ErrFeeNotReceived: 106 | return "no fee tx received for ticket" 107 | case ErrInvalidTicket: 108 | return "not a valid ticket tx" 109 | case ErrCannotBroadcastTicket: 110 | return "ticket transaction could not be broadcast" 111 | case ErrCannotBroadcastFee: 112 | return "fee transaction could not be broadcast" 113 | case ErrCannotBroadcastFeeUnknownOutputs: 114 | return "fee transaction could not be broadcast due to unknown outputs" 115 | case ErrInvalidTimestamp: 116 | return "old or reused timestamp" 117 | default: 118 | return "unknown error" 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /database/altsignaddr_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020-2021 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package database 6 | 7 | import ( 8 | "reflect" 9 | "testing" 10 | ) 11 | 12 | func exampleAltSignAddrData() *AltSignAddrData { 13 | return &AltSignAddrData{ 14 | AltSignAddr: randString(35, addrCharset), 15 | Req: string(randBytes(1000)), 16 | ReqSig: randString(96, sigCharset), 17 | Resp: string(randBytes(1000)), 18 | RespSig: randString(96, sigCharset), 19 | } 20 | } 21 | 22 | // ensureData will confirm that the provided data exists in the database. 23 | func ensureData(t *testing.T, ticketHash string, wantData *AltSignAddrData) { 24 | t.Helper() 25 | 26 | data, err := db.AltSignAddrData(ticketHash) 27 | if err != nil { 28 | t.Fatalf("unexpected error fetching alt sign address data: %v", err) 29 | } 30 | if !reflect.DeepEqual(wantData, data) { 31 | t.Fatal("want data different than actual") 32 | } 33 | } 34 | 35 | func testAltSignAddrData(t *testing.T) { 36 | ticketHash := randString(64, hexCharset) 37 | 38 | // Not added yet so no values should exist in the db. 39 | h, err := db.AltSignAddrData(ticketHash) 40 | if err != nil { 41 | t.Fatalf("unexpected error fetching alt sign address data: %v", err) 42 | } 43 | if h != nil { 44 | t.Fatal("expected no data") 45 | } 46 | 47 | // Insert an alt sign address. 48 | data := exampleAltSignAddrData() 49 | if err := db.InsertAltSignAddr(ticketHash, data); err != nil { 50 | t.Fatalf("unexpected error storing alt sign addr in database: %v", err) 51 | } 52 | 53 | ensureData(t, ticketHash, data) 54 | } 55 | 56 | func testInsertAltSignAddr(t *testing.T) { 57 | ticketHash := randString(64, hexCharset) 58 | 59 | // Not added yet so no values should exist in the db. 60 | ensureData(t, ticketHash, nil) 61 | 62 | data := exampleAltSignAddrData() 63 | // Clear alt sign addr for test. 64 | data.AltSignAddr = "" 65 | 66 | if err := db.InsertAltSignAddr(ticketHash, data); err == nil { 67 | t.Fatalf("expected error for insert blank address") 68 | } 69 | 70 | if err := db.InsertAltSignAddr(ticketHash, nil); err == nil { 71 | t.Fatalf("expected error for nil data") 72 | } 73 | 74 | // Still no change on errors. 75 | ensureData(t, ticketHash, nil) 76 | 77 | // Re-add alt sig addr. 78 | data.AltSignAddr = randString(35, addrCharset) 79 | 80 | // Insert an alt sign addr. 81 | if err := db.InsertAltSignAddr(ticketHash, data); err != nil { 82 | t.Fatalf("unexpected error storing alt sig addr in database: %v", err) 83 | } 84 | 85 | ensureData(t, ticketHash, data) 86 | 87 | // Further additions should error and not change the data. 88 | secondData := exampleAltSignAddrData() 89 | secondData.AltSignAddr = data.AltSignAddr 90 | if err := db.InsertAltSignAddr(ticketHash, secondData); err == nil { 91 | t.Fatalf("expected error for second alt sig addr addition") 92 | } 93 | 94 | ensureData(t, ticketHash, data) 95 | } 96 | 97 | func testDeleteAltSignAddr(t *testing.T) { 98 | ticketHash := randString(64, hexCharset) 99 | 100 | // Nothing to delete. 101 | if err := db.DeleteAltSignAddr(ticketHash); err != nil { 102 | t.Fatalf("unexpected error deleting nonexistant alt sign addr") 103 | } 104 | 105 | // Insert an alt sign addr. 106 | data := exampleAltSignAddrData() 107 | if err := db.InsertAltSignAddr(ticketHash, data); err != nil { 108 | t.Fatalf("unexpected error storing alt sign addr in database: %v", err) 109 | } 110 | 111 | ensureData(t, ticketHash, data) 112 | 113 | if err := db.DeleteAltSignAddr(ticketHash); err != nil { 114 | t.Fatalf("unexpected error deleting alt sign addr: %v", err) 115 | } 116 | 117 | ensureData(t, ticketHash, nil) 118 | } 119 | -------------------------------------------------------------------------------- /database/feexpub_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020-2024 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package database 6 | 7 | import ( 8 | "testing" 9 | ) 10 | 11 | func testFeeXPub(t *testing.T) { 12 | // A newly created DB should store the fee xpub it was initialized with. 13 | retrievedXPub, err := db.FeeXPub() 14 | if err != nil { 15 | t.Fatalf("error getting fee xpub: %v", err) 16 | } 17 | 18 | if retrievedXPub.Key != feeXPub { 19 | t.Fatalf("expected fee xpub %v, got %v", feeXPub, retrievedXPub.Key) 20 | } 21 | 22 | // The ID, last used index and retirement timestamp should all be 0 23 | if retrievedXPub.ID != 0 { 24 | t.Fatalf("expected xpub ID 0, got %d", retrievedXPub.ID) 25 | } 26 | if retrievedXPub.LastUsedIdx != 0 { 27 | t.Fatalf("expected xpub last used 0, got %d", retrievedXPub.LastUsedIdx) 28 | } 29 | if retrievedXPub.Retired != 0 { 30 | t.Fatalf("expected xpub retirement 0, got %d", retrievedXPub.Retired) 31 | } 32 | 33 | // Update address index. 34 | idx := uint32(99) 35 | err = db.SetLastAddressIndex(idx) 36 | if err != nil { 37 | t.Fatalf("error setting address index: %v", err) 38 | } 39 | 40 | // Check for updated value. 41 | retrievedXPub, err = db.FeeXPub() 42 | if err != nil { 43 | t.Fatalf("error getting fee xpub: %v", err) 44 | } 45 | if retrievedXPub.LastUsedIdx != idx { 46 | t.Fatalf("expected xpub last used %d, got %d", idx, retrievedXPub.LastUsedIdx) 47 | } 48 | 49 | // Key, ID and retirement timestamp should be unchanged. 50 | if retrievedXPub.Key != feeXPub { 51 | t.Fatalf("expected fee xpub %v, got %v", feeXPub, retrievedXPub.Key) 52 | } 53 | if retrievedXPub.ID != 0 { 54 | t.Fatalf("expected xpub ID 0, got %d", retrievedXPub.ID) 55 | } 56 | if retrievedXPub.Retired != 0 { 57 | t.Fatalf("expected xpub retirement 0, got %d", retrievedXPub.Retired) 58 | } 59 | } 60 | 61 | func testRetireFeeXPub(t *testing.T) { 62 | // Increment the last used index to simulate some usage. 63 | idx := uint32(99) 64 | err := db.SetLastAddressIndex(idx) 65 | if err != nil { 66 | t.Fatalf("error setting address index: %v", err) 67 | } 68 | 69 | // Ensure a previously used xpub is rejected. 70 | err = db.RetireXPub(feeXPub) 71 | if err == nil { 72 | t.Fatalf("previous xpub was not rejected") 73 | } 74 | 75 | const expectedErr = "provided xpub has already been used" 76 | if err == nil || err.Error() != expectedErr { 77 | t.Fatalf("incorrect error, expected %q, got %q", 78 | expectedErr, err.Error()) 79 | } 80 | 81 | // An unused xpub should be accepted. 82 | const feeXPub2 = "feexpub2" 83 | err = db.RetireXPub(feeXPub2) 84 | if err != nil { 85 | t.Fatalf("retiring xpub failed: %v", err) 86 | } 87 | 88 | // Retrieve the new xpub. Index should be incremented, last addr should be 89 | // reset to 0, key should not be retired. 90 | retrievedXPub, err := db.FeeXPub() 91 | if err != nil { 92 | t.Fatalf("error getting fee xpub: %v", err) 93 | } 94 | 95 | if retrievedXPub.Key != feeXPub2 { 96 | t.Fatalf("expected fee xpub %q, got %q", feeXPub2, retrievedXPub.Key) 97 | } 98 | if retrievedXPub.ID != 1 { 99 | t.Fatalf("expected xpub ID 1, got %d", retrievedXPub.ID) 100 | } 101 | if retrievedXPub.LastUsedIdx != 0 { 102 | t.Fatalf("expected xpub last used 0, got %d", retrievedXPub.LastUsedIdx) 103 | } 104 | if retrievedXPub.Retired != 0 { 105 | t.Fatalf("expected xpub retirement 0, got %d", retrievedXPub.Retired) 106 | } 107 | 108 | // Old xpub should have retired field set. 109 | xpubs, err := db.AllXPubs() 110 | if err != nil { 111 | t.Fatalf("error getting all fee xpubs: %v", err) 112 | } 113 | 114 | if xpubs[0].Retired == 0 { 115 | t.Fatalf("old xpub retired field not set") 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /database/votechange.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020-2024 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package database 6 | 7 | import ( 8 | "encoding/json" 9 | "fmt" 10 | "math" 11 | 12 | bolt "go.etcd.io/bbolt" 13 | ) 14 | 15 | // VoteChangeRecord is serialized to json and stored in bbolt db. The json keys 16 | // are deliberately kept short because they are duplicated many times in the db. 17 | type VoteChangeRecord struct { 18 | Request string `json:"req"` 19 | RequestSignature string `json:"reqs"` 20 | Response string `json:"rsp"` 21 | ResponseSignature string `json:"rsps"` 22 | } 23 | 24 | // SaveVoteChange will insert the provided vote change record into the database, 25 | // and if this breaches the maximum amount of allowed records, delete the oldest 26 | // one which is currently stored. Records are stored using a serially increasing 27 | // integer as the key. 28 | func (vdb *VspDatabase) SaveVoteChange(ticketHash string, record VoteChangeRecord) error { 29 | return vdb.db.Update(func(tx *bolt.Tx) error { 30 | 31 | // Create or get a bucket for this ticket. 32 | bkt, err := tx.Bucket(vspBktK).Bucket(voteChangeBktK). 33 | CreateBucketIfNotExists([]byte(ticketHash)) 34 | if err != nil { 35 | return fmt.Errorf("failed to create vote change bucket (ticketHash=%s): %w", 36 | ticketHash, err) 37 | } 38 | 39 | // Loop through the bucket to count the records, as well as finding the 40 | // most recent and the oldest record. 41 | var count int 42 | newest := uint32(0) 43 | oldest := uint32(math.MaxUint32) 44 | err = bkt.ForEach(func(k, _ []byte) error { 45 | count++ 46 | key := bytesToUint32(k) 47 | if key > newest { 48 | newest = key 49 | } 50 | if key < oldest { 51 | oldest = key 52 | } 53 | return nil 54 | }) 55 | if err != nil { 56 | return fmt.Errorf("error iterating over vote change bucket: %w", err) 57 | } 58 | 59 | // If bucket is at (or over) the limit of max allowed records, remove 60 | // the oldest one. 61 | if count >= vdb.maxVoteChangeRecords { 62 | err = bkt.Delete(uint32ToBytes(oldest)) 63 | if err != nil { 64 | return fmt.Errorf("failed to delete old vote change record: %w", err) 65 | } 66 | } 67 | 68 | // Insert record with index 0 if the bucket is currently empty, 69 | // otherwise use most recent + 1. 70 | var newKey uint32 71 | if count > 0 { 72 | newKey = newest + 1 73 | } 74 | 75 | // Insert record. 76 | recordBytes, err := json.Marshal(record) 77 | if err != nil { 78 | return fmt.Errorf("could not marshal vote change record: %w", err) 79 | } 80 | err = bkt.Put(uint32ToBytes(newKey), recordBytes) 81 | if err != nil { 82 | return fmt.Errorf("could not store vote change record: %w", err) 83 | } 84 | 85 | return nil 86 | }) 87 | } 88 | 89 | // GetVoteChanges retrieves all of the stored vote change records for the 90 | // provided ticket hash. 91 | func (vdb *VspDatabase) GetVoteChanges(ticketHash string) (map[uint32]VoteChangeRecord, error) { 92 | 93 | records := make(map[uint32]VoteChangeRecord) 94 | 95 | err := vdb.db.View(func(tx *bolt.Tx) error { 96 | bkt := tx.Bucket(vspBktK).Bucket(voteChangeBktK). 97 | Bucket([]byte(ticketHash)) 98 | 99 | if bkt == nil { 100 | return nil 101 | } 102 | 103 | err := bkt.ForEach(func(k, v []byte) error { 104 | var record VoteChangeRecord 105 | err := json.Unmarshal(v, &record) 106 | if err != nil { 107 | return fmt.Errorf("could not unmarshal vote change record: %w", err) 108 | } 109 | 110 | records[bytesToUint32(k)] = record 111 | 112 | return nil 113 | }) 114 | if err != nil { 115 | return fmt.Errorf("error iterating over vote change bucket: %w", err) 116 | } 117 | 118 | return nil 119 | }) 120 | 121 | return records, err 122 | } 123 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vspd 2 | 3 | [![Build Status](https://github.com/decred/vspd/workflows/Build%20and%20Test/badge.svg)](https://github.com/decred/vspd/actions) 4 | [![ISC License](https://img.shields.io/badge/license-ISC-blue.svg)](http://copyfree.org) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/decred/vspd)](https://goreportcard.com/report/github.com/decred/vspd) 6 | [![Release](https://img.shields.io/github/release/decred/vspd.svg?style=flat-square)](https://github.com/decred/vspd/releases/latest) 7 | 8 | 9 | 10 | [First announced in 2020](https://blog.decred.org/2020/06/02/A-More-Private-Way-to-Stake/), 11 | vspd is a from scratch implementation of a Voting Service Provider (VSP) for the 12 | Decred network. 13 | 14 | A VSP running vspd can be used to vote on any ticket - tickets do not need to 15 | be purchased with any special conditions such as dedicated outputs for paying 16 | VSP fees. Fees are paid directly to the VSP with an independent on-chain 17 | transaction. 18 | 19 | To use vspd, ticket holders must prove ownership of their ticket with a 20 | cryptographic signature, pay the fee requested by the VSP, and submit a private 21 | key which enables the VSP to vote the ticket. Once this process is complete the 22 | VSP will add the ticket to a pool of always-online voting wallets. 23 | 24 | ## Features 25 | 26 | - **API** - Tickets are registered with the VSP using a JSON HTTP API. For more 27 | detail on the API and its usage, read [api.md](./docs/api.md) 28 | 29 | - **Web front-end** - A minimal website (no JavaScript) providing public pool 30 | stats. A password protected admin page provides an overview of system status, 31 | enables searching for tickets and downloading database backups. 32 | 33 | - **Two-way accountability** - All vspd requests and responses are signed by 34 | their sender, which enables both the client and the server to hold each other 35 | accountable in the case of misbehaviour. For more detail and examples, read 36 | [two-way-accountability.md](./docs/two-way-accountability.md). 37 | 38 | - **Dynamic fees** - Clients must request a new fee address and amount for every 39 | ticket. When these are given to a client, there is an associated expiry 40 | period. If the fee is not paid in this period, the client must request a new 41 | fee. This enables the VSP admin to change their fee as often as they like. 42 | 43 | ## Implementation 44 | 45 | vspd is built and tested on go 1.24 and 1.25, making use of the following 46 | libraries: 47 | 48 | - [gin-gonic/gin](https://github.com/gin-gonic/gin) webserver. 49 | 50 | - [etcd-io/bbolt](https://github.com/etcd-io/bbolt) key-value database. 51 | 52 | - [jrick/wsrpc](https://github.com/jrick/wsrpc) for RPC communication with dcrd 53 | and dcrwallet. 54 | 55 | ## Deployment 56 | 57 | A vspd deployment consists of a single front-end server which handles web 58 | requests, and a number of remote servers which host voting wallets. For more 59 | information about deploying vspd, check out 60 | [deployment.md](./docs/deployment.md). 61 | 62 | The process for listing a new VSP on [decred.org](https://decred.org/vsp/), and 63 | consequently in Decrediton, is detailed in [listing.md](./docs/listing.md). 64 | 65 | ## Development 66 | 67 | ### Test Harness 68 | 69 | A test harness is provided in `harness.sh`. The test harness uses tmux to start 70 | a testnet instance of dcrd, multiple dcrwallets, and finally vspd. Further 71 | documentation can be found in [harness.sh](./harness.sh). 72 | 73 | ### Web server debug mode 74 | 75 | The config option `--webserverdebug` will: 76 | 77 | - Force HTML templates to be reloaded on every web request. 78 | - Reload the cached homepage data every second rather than every 5 minutes. 79 | - Enable detailed webserver logging to the terminal (does not get written to log 80 | file). 81 | 82 | ## Issue Tracker 83 | 84 | The [integrated GitHub issue tracker](https://github.com/decred/vspd/issues) 85 | is used for this project. 86 | 87 | ## License 88 | 89 | vspd is licensed under the [copyfree](http://copyfree.org) ISC License. 90 | -------------------------------------------------------------------------------- /internal/webapi/recovery.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020-2023 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package webapi 6 | 7 | import ( 8 | "bytes" 9 | "errors" 10 | "fmt" 11 | "net" 12 | "net/http" 13 | "net/http/httputil" 14 | "os" 15 | "runtime" 16 | "strings" 17 | 18 | "github.com/decred/slog" 19 | "github.com/gin-gonic/gin" 20 | ) 21 | 22 | var ( 23 | dunno = []byte("???") 24 | centerDot = []byte("·") 25 | dot = []byte(".") 26 | slash = []byte("/") 27 | ) 28 | 29 | // recovery returns a middleware that recovers from any panics which occur in 30 | // request handlers. It logs the panic, a stack trace, and the full request 31 | // details. It also ensure the client receives a 500 response rather than no 32 | // response at all. 33 | func recovery(log slog.Logger) gin.HandlerFunc { 34 | return func(c *gin.Context) { 35 | defer func() { 36 | if err := recover(); err != nil { 37 | // Check for a broken connection, as it is not really a 38 | // condition that warrants a panic stack trace. 39 | var brokenPipe bool 40 | if ne, ok := err.(*net.OpError); ok { 41 | var se *os.SyscallError 42 | if errors.As(ne, &se) { 43 | seStr := strings.ToLower(se.Error()) 44 | if strings.Contains(seStr, "broken pipe") || 45 | strings.Contains(seStr, "connection reset by peer") { 46 | brokenPipe = true 47 | } 48 | } 49 | } 50 | 51 | httpRequest, _ := httputil.DumpRequest(c.Request, true) 52 | if brokenPipe { 53 | log.Errorf("%s\n%s", err, httpRequest) 54 | } else { 55 | log.Errorf("panic recovered: %s\n\n%s\n\n%s", 56 | err, formattedStack(), httpRequest) 57 | } 58 | 59 | if brokenPipe { 60 | // If the connection is dead, we can't write a status to it. 61 | _ = c.Error(err.(error)) 62 | c.Abort() 63 | } else { 64 | c.AbortWithStatus(http.StatusInternalServerError) 65 | } 66 | } 67 | }() 68 | c.Next() 69 | } 70 | } 71 | 72 | // formattedStack returns a nicely formatted stack frame. 73 | func formattedStack() []byte { 74 | buf := new(bytes.Buffer) 75 | // As we loop, we open files and read them. These variables record the 76 | // currently loaded file. 77 | var lines [][]byte 78 | var lastFile string 79 | for i := 3; ; i++ { 80 | pc, file, line, ok := runtime.Caller(i) 81 | if !ok { 82 | break 83 | } 84 | // Print this much at least. If we can't find the source, it won't show. 85 | fmt.Fprintf(buf, "%s:%d (0x%x)\n", file, line, pc) 86 | if file != lastFile { 87 | data, err := os.ReadFile(file) 88 | if err != nil { 89 | continue 90 | } 91 | lines = bytes.Split(data, []byte{'\n'}) 92 | lastFile = file 93 | } 94 | fmt.Fprintf(buf, "\t%s: %s\n", function(pc), source(lines, line)) 95 | } 96 | return buf.Bytes() 97 | } 98 | 99 | // source returns a space-trimmed slice of the n'th line. 100 | func source(lines [][]byte, n int) []byte { 101 | n-- // in stack trace, lines are 1-indexed but our array is 0-indexed 102 | if n < 0 || n >= len(lines) { 103 | return dunno 104 | } 105 | return bytes.TrimSpace(lines[n]) 106 | } 107 | 108 | // function returns, if possible, the name of the function containing the PC. 109 | func function(pc uintptr) []byte { 110 | fn := runtime.FuncForPC(pc) 111 | if fn == nil { 112 | return dunno 113 | } 114 | name := []byte(fn.Name()) 115 | // The name includes the path name to the package, which is unnecessary 116 | // since the file name is already included. Plus, it has center dots. 117 | // That is, we see 118 | // runtime/debug.*T·ptrmethod 119 | // and want 120 | // *T.ptrmethod 121 | // Also the package path might contain dot (e.g. code.google.com/...), 122 | // so first eliminate the path prefix 123 | if lastSlash := bytes.LastIndex(name, slash); lastSlash >= 0 { 124 | name = name[lastSlash+1:] 125 | } 126 | if period := bytes.Index(name, dot); period >= 0 { 127 | name = name[period+1:] 128 | } 129 | name = bytes.ReplaceAll(name, centerDot, dot) 130 | return name 131 | } 132 | -------------------------------------------------------------------------------- /internal/webapi/cache.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020-2024 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package webapi 6 | 7 | import ( 8 | "errors" 9 | "sync" 10 | "time" 11 | 12 | "github.com/decred/slog" 13 | "github.com/decred/vspd/database" 14 | "github.com/decred/vspd/rpc" 15 | "github.com/dustin/go-humanize" 16 | ) 17 | 18 | // cache is used to store values which are commonly used by the API, so 19 | // repeated web requests don't repeatedly trigger DB or RPC calls. 20 | type cache struct { 21 | // data is the cached data. 22 | data cacheData 23 | // mtx must be held to read/write cache data. 24 | mtx sync.RWMutex 25 | 26 | log slog.Logger 27 | db *database.VspDatabase 28 | dcrd rpc.DcrdConnect 29 | wallets rpc.WalletConnect 30 | } 31 | 32 | type cacheData struct { 33 | // Initialized is set true after all of the below values have been set for 34 | // the first time. 35 | Initialized bool 36 | 37 | UpdateTime time.Time 38 | PubKey string 39 | DatabaseSize string 40 | Voting int64 41 | Voted int64 42 | Expired int64 43 | Missed int64 44 | VotingWalletsOnline int64 45 | TotalVotingWallets int64 46 | BlockHeight uint32 47 | NetworkProportion float32 48 | ExpiredProportion float32 49 | MissedProportion float32 50 | } 51 | 52 | func (c *cache) initialized() bool { 53 | c.mtx.RLock() 54 | defer c.mtx.RUnlock() 55 | 56 | return c.data.Initialized 57 | } 58 | 59 | func (c *cache) getData() cacheData { 60 | c.mtx.RLock() 61 | defer c.mtx.RUnlock() 62 | 63 | return c.data 64 | } 65 | 66 | // newCache creates a new cache and initializes it with static values. 67 | func newCache(signPubKey string, log slog.Logger, db *database.VspDatabase, 68 | dcrd rpc.DcrdConnect, wallets rpc.WalletConnect) *cache { 69 | return &cache{ 70 | data: cacheData{ 71 | PubKey: signPubKey, 72 | }, 73 | log: log, 74 | db: db, 75 | dcrd: dcrd, 76 | wallets: wallets, 77 | } 78 | } 79 | 80 | // update will use the provided database and RPC connections to update the 81 | // dynamic values in the cache. 82 | func (c *cache) update() error { 83 | dbSize, err := c.db.Size() 84 | if err != nil { 85 | return err 86 | } 87 | 88 | // Get latest counts of voting, voted, expired and missed tickets. 89 | voting, voted, expired, missed, err := c.db.CountTickets() 90 | if err != nil { 91 | return err 92 | } 93 | 94 | // Get latest best block height. 95 | dcrdClient, _, err := c.dcrd.Client() 96 | if err != nil { 97 | return err 98 | } 99 | 100 | bestBlock, err := dcrdClient.GetBestBlockHeader() 101 | if err != nil { 102 | return err 103 | } 104 | 105 | if bestBlock.PoolSize == 0 { 106 | return errors.New("dcr node reports a network ticket pool size of zero") 107 | } 108 | 109 | clients, failedConnections := c.wallets.Clients() 110 | if len(clients) == 0 { 111 | c.log.Error("Could not connect to any wallets") 112 | } else if len(failedConnections) > 0 { 113 | c.log.Errorf("Failed to connect to %d wallet(s), proceeding with only %d", 114 | len(failedConnections), len(clients)) 115 | } 116 | 117 | c.mtx.Lock() 118 | defer c.mtx.Unlock() 119 | 120 | c.data.Initialized = true 121 | c.data.UpdateTime = time.Now() 122 | c.data.DatabaseSize = humanize.Bytes(dbSize) 123 | c.data.Voting = voting 124 | c.data.Voted = voted 125 | c.data.TotalVotingWallets = int64(len(clients) + len(failedConnections)) 126 | c.data.VotingWalletsOnline = int64(len(clients)) 127 | c.data.Expired = expired 128 | c.data.Missed = missed 129 | c.data.BlockHeight = bestBlock.Height 130 | c.data.NetworkProportion = float32(voting) / float32(bestBlock.PoolSize) 131 | 132 | total := voted + expired + missed 133 | 134 | // Prevent dividing by zero when pool has no voted/expired/missed tickets. 135 | if total == 0 { 136 | c.data.ExpiredProportion = 0 137 | c.data.MissedProportion = 0 138 | } else { 139 | c.data.ExpiredProportion = float32(expired) / float32(total) 140 | c.data.MissedProportion = float32(missed) / float32(total) 141 | } 142 | 143 | return nil 144 | } 145 | -------------------------------------------------------------------------------- /database/altsignaddr.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020-2021 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package database 6 | 7 | import ( 8 | "errors" 9 | "fmt" 10 | 11 | bolt "go.etcd.io/bbolt" 12 | ) 13 | 14 | // The keys used to store alternate signing addresses in the database. 15 | var ( 16 | altSignAddrK = []byte("altsig") 17 | reqK = []byte("req") 18 | reqSigK = []byte("reqsig") 19 | respK = []byte("res") 20 | respSigK = []byte("ressig") 21 | ) 22 | 23 | // AltSignAddrData holds the information needed to prove that a client added an 24 | // alternate signing address. 25 | type AltSignAddrData struct { 26 | // AltSignAddr is the new alternate signing address. It is base 58 encoded. 27 | AltSignAddr string 28 | // Req is the original request to set an alternate signing address. 29 | Req string 30 | // ReqSig is the request's signature signed by the commitment address of the 31 | // corresponding ticket. It is base 64 encoded. 32 | ReqSig string 33 | // Resp is the original response from the server to the alternate signing 34 | // address. 35 | Resp string 36 | // RespSig is the response's signature signed by the server. It is base 64 37 | // encoded. 38 | RespSig string 39 | } 40 | 41 | // InsertAltSignAddr will insert the provided alternate signing address into the 42 | // database. Returns an error if data for the ticket hash already exist. 43 | // 44 | // Passed data must have no empty fields. 45 | func (vdb *VspDatabase) InsertAltSignAddr(ticketHash string, data *AltSignAddrData) error { 46 | if data == nil { 47 | return errors.New("alt sign addr data must not be nil for inserts") 48 | } 49 | 50 | if data.AltSignAddr == "" || len(data.Req) == 0 || data.ReqSig == "" || 51 | len(data.Resp) == 0 || data.RespSig == "" { 52 | return errors.New("alt sign addr data has empty parameters") 53 | } 54 | 55 | return vdb.db.Update(func(tx *bolt.Tx) error { 56 | altSignAddrBkt := tx.Bucket(vspBktK).Bucket(altSignAddrBktK) 57 | 58 | // Create a bucket for the new alt sign addr. Returns an error if bucket 59 | // already exists. 60 | bkt, err := altSignAddrBkt.CreateBucket([]byte(ticketHash)) 61 | if err != nil { 62 | return fmt.Errorf("could not create bucket for alt sign addr: %w", err) 63 | } 64 | 65 | if err := bkt.Put(altSignAddrK, []byte(data.AltSignAddr)); err != nil { 66 | return err 67 | } 68 | 69 | if err := bkt.Put(reqK, []byte(data.Req)); err != nil { 70 | return err 71 | } 72 | 73 | if err := bkt.Put(reqSigK, []byte(data.ReqSig)); err != nil { 74 | return err 75 | } 76 | 77 | if err := bkt.Put(respK, []byte(data.Resp)); err != nil { 78 | return err 79 | } 80 | return bkt.Put(respSigK, []byte(data.RespSig)) 81 | }) 82 | } 83 | 84 | // DeleteAltSignAddr deletes an alternate signing address from the database. 85 | // Does not error if there is no record in the database to delete. 86 | func (vdb *VspDatabase) DeleteAltSignAddr(ticketHash string) error { 87 | return vdb.db.Update(func(tx *bolt.Tx) error { 88 | altSignAddrBkt := tx.Bucket(vspBktK).Bucket(altSignAddrBktK) 89 | 90 | // Don't attempt delete if doesn't exist. 91 | bkt := altSignAddrBkt.Bucket([]byte(ticketHash)) 92 | if bkt == nil { 93 | return nil 94 | } 95 | 96 | err := altSignAddrBkt.DeleteBucket([]byte(ticketHash)) 97 | if err != nil { 98 | return fmt.Errorf("could not delete altsignaddr: %w", err) 99 | } 100 | 101 | return nil 102 | }) 103 | } 104 | 105 | // AltSignAddrData retrieves a ticket's alternate signing data. Existence of an 106 | // alternate signing address can be inferred by no error and nil data return. 107 | func (vdb *VspDatabase) AltSignAddrData(ticketHash string) (*AltSignAddrData, error) { 108 | var h *AltSignAddrData 109 | return h, vdb.db.View(func(tx *bolt.Tx) error { 110 | bkt := tx.Bucket(vspBktK).Bucket(altSignAddrBktK).Bucket([]byte(ticketHash)) 111 | if bkt == nil { 112 | return nil 113 | } 114 | h = &AltSignAddrData{ 115 | AltSignAddr: string(bkt.Get(altSignAddrK)), 116 | Req: string(bkt.Get(reqK)), 117 | ReqSig: string(bkt.Get(reqSigK)), 118 | Resp: string(bkt.Get(respK)), 119 | RespSig: string(bkt.Get(respSigK)), 120 | } 121 | return nil 122 | }) 123 | } 124 | -------------------------------------------------------------------------------- /internal/vspd/databaseintegrity.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020-2023 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package vspd 6 | 7 | import ( 8 | "context" 9 | "fmt" 10 | 11 | "github.com/decred/vspd/database" 12 | ) 13 | 14 | // checkDatabaseIntegrity starts the process of ensuring that all data expected 15 | // to be in the database is present and up to date. 16 | func (v *Vspd) checkDatabaseIntegrity(ctx context.Context) error { 17 | err := v.checkPurchaseHeights() 18 | if err != nil { 19 | return fmt.Errorf("checkPurchaseHeights error: %w", err) 20 | } 21 | 22 | err = v.checkRevoked(ctx) 23 | if err != nil { 24 | return fmt.Errorf("checkRevoked error: %w", err) 25 | } 26 | 27 | return nil 28 | } 29 | 30 | // checkPurchaseHeights ensures a purchase height is recorded for all confirmed 31 | // tickets in the database. This is necessary because of an old bug which, in 32 | // some circumstances, would prevent purchase height from being stored. 33 | func (v *Vspd) checkPurchaseHeights() error { 34 | missing, err := v.db.GetMissingPurchaseHeight() 35 | if err != nil { 36 | // Cannot proceed if this fails, return. 37 | return fmt.Errorf("db.GetMissingPurchaseHeight error: %w", err) 38 | } 39 | 40 | if len(missing) == 0 { 41 | // Nothing to do, return. 42 | return nil 43 | } 44 | 45 | v.log.Warnf("%d tickets are missing purchase heights", len(missing)) 46 | 47 | dcrdClient, _, err := v.dcrd.Client() 48 | if err != nil { 49 | // Cannot proceed if this fails, return. 50 | return err 51 | } 52 | 53 | fixed := 0 54 | for _, ticket := range missing { 55 | tktTx, err := dcrdClient.GetRawTransaction(ticket.Hash) 56 | if err != nil { 57 | // Just log and continue, other tickets might succeed. 58 | v.log.Errorf("Could not get raw tx for ticket %s: %v", ticket.Hash, err) 59 | continue 60 | } 61 | ticket.PurchaseHeight = tktTx.BlockHeight 62 | err = v.db.UpdateTicket(ticket) 63 | if err != nil { 64 | // Just log and continue, other tickets might succeed. 65 | v.log.Errorf("Could not insert purchase height for ticket %s: %v", ticket.Hash, err) 66 | continue 67 | } 68 | fixed++ 69 | } 70 | 71 | v.log.Infof("Added missing purchase height to %d tickets", fixed) 72 | return nil 73 | } 74 | 75 | // checkRevoked ensures that any tickets in the database with outcome set to 76 | // revoked are updated to either expired or missed. 77 | func (v *Vspd) checkRevoked(ctx context.Context) error { 78 | revoked, err := v.db.GetRevokedTickets() 79 | if err != nil { 80 | return fmt.Errorf("db.GetRevoked error: %w", err) 81 | } 82 | 83 | if len(revoked) == 0 { 84 | // Nothing to do, return. 85 | return nil 86 | } 87 | 88 | v.log.Warnf("Updating %s in revoked status, this may take a while...", 89 | pluralize(len(revoked), "ticket")) 90 | 91 | dcrdClient, _, err := v.dcrd.Client() 92 | if err != nil { 93 | return err 94 | } 95 | 96 | // Search for the transactions which spend these tickets, starting at the 97 | // earliest height one of them matured. 98 | startHeight := revoked.EarliestPurchaseHeight() + int64(v.network.TicketMaturity) 99 | 100 | spent, _, err := v.findSpentTickets(ctx, dcrdClient, revoked, startHeight) 101 | if err != nil { 102 | return fmt.Errorf("findSpentTickets error: %w", err) 103 | } 104 | 105 | fixedMissed := 0 106 | fixedExpired := 0 107 | 108 | // Update database with correct voted status. 109 | for hash, spentTicket := range spent { 110 | switch { 111 | case spentTicket.voted(): 112 | v.log.Errorf("Ticket voted but was recorded as revoked. Please contact "+ 113 | "developers so this can be investigated (ticketHash=%s)", hash) 114 | continue 115 | case spentTicket.missed(): 116 | spentTicket.dbTicket.Outcome = database.Missed 117 | fixedMissed++ 118 | default: 119 | spentTicket.dbTicket.Outcome = database.Expired 120 | fixedExpired++ 121 | } 122 | 123 | err = v.db.UpdateTicket(spentTicket.dbTicket) 124 | if err != nil { 125 | v.log.Errorf("Could not update status of ticket %s: %v", hash, err) 126 | } 127 | } 128 | 129 | v.log.Infof("%s updated (%d missed, %d expired)", 130 | pluralize(fixedExpired+fixedMissed, "revoked ticket"), 131 | fixedMissed, fixedExpired) 132 | 133 | return nil 134 | } 135 | -------------------------------------------------------------------------------- /types/types.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020-2024 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package types 6 | 7 | type ErrorResponse struct { 8 | Code ErrorCode `json:"code"` 9 | Message string `json:"message"` 10 | } 11 | 12 | func (e ErrorResponse) Error() string { return e.Message } 13 | 14 | type VspInfoResponse struct { 15 | APIVersions []int64 `json:"apiversions"` 16 | Timestamp int64 `json:"timestamp"` 17 | PubKey []byte `json:"pubkey"` 18 | FeePercentage float64 `json:"feepercentage"` 19 | VspClosed bool `json:"vspclosed"` 20 | VspClosedMsg string `json:"vspclosedmsg"` 21 | Network string `json:"network"` 22 | VspdVersion string `json:"vspdversion"` 23 | Voting int64 `json:"voting"` 24 | Voted int64 `json:"voted"` 25 | TotalVotingWallets int64 `json:"totalvotingwallets"` 26 | VotingWalletsOnline int64 `json:"votingwalletsonline"` 27 | Expired int64 `json:"expired"` 28 | Missed int64 `json:"missed"` 29 | BlockHeight uint32 `json:"blockheight"` 30 | NetworkProportion float32 `json:"estimatednetworkproportion"` 31 | } 32 | 33 | type FeeAddressRequest struct { 34 | Timestamp int64 `json:"timestamp" binding:"required"` 35 | TicketHash string `json:"tickethash" binding:"required"` 36 | TicketHex string `json:"tickethex" binding:"required"` 37 | ParentHex string `json:"parenthex" binding:"required"` 38 | } 39 | 40 | type FeeAddressResponse struct { 41 | Timestamp int64 `json:"timestamp"` 42 | FeeAddress string `json:"feeaddress"` 43 | FeeAmount int64 `json:"feeamount"` 44 | Expiration int64 `json:"expiration"` 45 | Request []byte `json:"request"` 46 | } 47 | 48 | type PayFeeRequest struct { 49 | Timestamp int64 `json:"timestamp" binding:"required"` 50 | TicketHash string `json:"tickethash" binding:"required"` 51 | FeeTx string `json:"feetx" binding:"required"` 52 | VotingKey string `json:"votingkey" binding:"required"` 53 | VoteChoices map[string]string `json:"votechoices" binding:"required"` 54 | TSpendPolicy map[string]string `json:"tspendpolicy" binding:"max=3"` 55 | TreasuryPolicy map[string]string `json:"treasurypolicy" binding:"max=3"` 56 | } 57 | 58 | type PayFeeResponse struct { 59 | Timestamp int64 `json:"timestamp"` 60 | Request []byte `json:"request"` 61 | } 62 | 63 | type SetVoteChoicesRequest struct { 64 | Timestamp int64 `json:"timestamp" binding:"required"` 65 | TicketHash string `json:"tickethash" binding:"required"` 66 | VoteChoices map[string]string `json:"votechoices" binding:"required"` 67 | TSpendPolicy map[string]string `json:"tspendpolicy" binding:"max=3"` 68 | TreasuryPolicy map[string]string `json:"treasurypolicy" binding:"max=3"` 69 | } 70 | 71 | type SetVoteChoicesResponse struct { 72 | Timestamp int64 `json:"timestamp"` 73 | Request []byte `json:"request"` 74 | } 75 | 76 | type TicketStatusRequest struct { 77 | TicketHash string `json:"tickethash" binding:"required"` 78 | } 79 | 80 | type TicketStatusResponse struct { 81 | Timestamp int64 `json:"timestamp"` 82 | TicketConfirmed bool `json:"ticketconfirmed"` 83 | FeeTxStatus string `json:"feetxstatus"` 84 | FeeTxHash string `json:"feetxhash"` 85 | AltSignAddress string `json:"altsignaddress"` 86 | VoteChoices map[string]string `json:"votechoices"` 87 | TSpendPolicy map[string]string `json:"tspendpolicy"` 88 | TreasuryPolicy map[string]string `json:"treasurypolicy"` 89 | Request []byte `json:"request"` 90 | } 91 | 92 | type SetAltSignAddrRequest struct { 93 | Timestamp int64 `json:"timestamp" binding:"required"` 94 | TicketHash string `json:"tickethash" binding:"required"` 95 | TicketHex string `json:"tickethex" binding:"required"` 96 | ParentHex string `json:"parenthex" binding:"required"` 97 | AltSignAddress string `json:"altsignaddress" binding:"required"` 98 | } 99 | 100 | type SetAltSignAddrResponse struct { 101 | Timestamp int64 `json:"timestamp"` 102 | Request []byte `json:"request"` 103 | } 104 | -------------------------------------------------------------------------------- /database/feexpub.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020-2024 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package database 6 | 7 | import ( 8 | "encoding/json" 9 | "errors" 10 | "fmt" 11 | "time" 12 | 13 | bolt "go.etcd.io/bbolt" 14 | ) 15 | 16 | // FeeXPub is serialized to json and stored in bbolt db. 17 | type FeeXPub struct { 18 | ID uint32 `json:"id"` 19 | Key string `json:"key"` 20 | LastUsedIdx uint32 `json:"lastusedidx"` 21 | // Retired is a unix timestamp representing the moment the key was retired, 22 | // or zero for the currently active key. 23 | Retired int64 `json:"retired"` 24 | } 25 | 26 | // insertFeeXPub stores the provided pubkey in the database, regardless of 27 | // whether a value pre-exists. 28 | func insertFeeXPub(tx *bolt.Tx, xpub FeeXPub) error { 29 | vspBkt := tx.Bucket(vspBktK) 30 | 31 | keyBkt, err := vspBkt.CreateBucketIfNotExists(xPubBktK) 32 | if err != nil { 33 | return fmt.Errorf("failed to get %s bucket: %w", string(xPubBktK), err) 34 | } 35 | 36 | keyBytes, err := json.Marshal(xpub) 37 | if err != nil { 38 | return fmt.Errorf("could not marshal xpub: %w", err) 39 | } 40 | 41 | err = keyBkt.Put(uint32ToBytes(xpub.ID), keyBytes) 42 | if err != nil { 43 | return fmt.Errorf("could not store xpub: %w", err) 44 | } 45 | 46 | return nil 47 | } 48 | 49 | // FeeXPub retrieves the currently active extended pubkey used for generating 50 | // fee addresses from the database. 51 | func (vdb *VspDatabase) FeeXPub() (FeeXPub, error) { 52 | xpubs, err := vdb.AllXPubs() 53 | if err != nil { 54 | return FeeXPub{}, err 55 | } 56 | 57 | // Find the active xpub - the one with the highest ID. 58 | var highest uint32 59 | for id := range xpubs { 60 | if id > highest { 61 | highest = id 62 | } 63 | } 64 | 65 | return xpubs[highest], nil 66 | } 67 | 68 | // RetireXPub will mark the currently active xpub key as retired and insert the 69 | // provided pubkey as the currently active one. 70 | func (vdb *VspDatabase) RetireXPub(xpub string) error { 71 | // Ensure the new xpub has never been used before. 72 | xpubs, err := vdb.AllXPubs() 73 | if err != nil { 74 | return err 75 | } 76 | for _, x := range xpubs { 77 | if x.Key == xpub { 78 | return errors.New("provided xpub has already been used") 79 | } 80 | } 81 | 82 | current, err := vdb.FeeXPub() 83 | if err != nil { 84 | return err 85 | } 86 | current.Retired = time.Now().Unix() 87 | 88 | return vdb.db.Update(func(tx *bolt.Tx) error { 89 | // Store the retired xpub. 90 | err := insertFeeXPub(tx, current) 91 | if err != nil { 92 | return err 93 | } 94 | 95 | // Insert new xpub. 96 | newKey := FeeXPub{ 97 | ID: current.ID + 1, 98 | Key: xpub, 99 | LastUsedIdx: 0, 100 | Retired: 0, 101 | } 102 | err = insertFeeXPub(tx, newKey) 103 | if err != nil { 104 | return err 105 | } 106 | 107 | return nil 108 | }) 109 | } 110 | 111 | // AllXPubs retrieves the current and any retired extended pubkeys from the 112 | // database. 113 | func (vdb *VspDatabase) AllXPubs() (map[uint32]FeeXPub, error) { 114 | xpubs := make(map[uint32]FeeXPub) 115 | 116 | err := vdb.db.View(func(tx *bolt.Tx) error { 117 | bkt := tx.Bucket(vspBktK).Bucket(xPubBktK) 118 | 119 | if bkt == nil { 120 | return fmt.Errorf("%s bucket doesn't exist", string(xPubBktK)) 121 | } 122 | 123 | err := bkt.ForEach(func(k, v []byte) error { 124 | var xpub FeeXPub 125 | err := json.Unmarshal(v, &xpub) 126 | if err != nil { 127 | return fmt.Errorf("could not unmarshal xpub key: %w", err) 128 | } 129 | 130 | xpubs[bytesToUint32(k)] = xpub 131 | 132 | return nil 133 | }) 134 | if err != nil { 135 | return fmt.Errorf("error iterating over %s bucket: %w", string(xPubBktK), err) 136 | } 137 | 138 | return nil 139 | }) 140 | 141 | return xpubs, err 142 | } 143 | 144 | // SetLastAddressIndex updates the last index used to derive a new fee address 145 | // from the fee xpub key. 146 | func (vdb *VspDatabase) SetLastAddressIndex(idx uint32) error { 147 | current, err := vdb.FeeXPub() 148 | if err != nil { 149 | return err 150 | } 151 | 152 | current.LastUsedIdx = idx 153 | 154 | return vdb.db.Update(func(tx *bolt.Tx) error { 155 | return insertFeeXPub(tx, current) 156 | }) 157 | } 158 | -------------------------------------------------------------------------------- /internal/config/network.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020-2024 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package config 6 | 7 | import ( 8 | "fmt" 9 | 10 | "github.com/decred/dcrd/chaincfg/v3" 11 | ) 12 | 13 | type Network struct { 14 | *chaincfg.Params 15 | DcrdRPCServerPort string 16 | WalletRPCServerPort string 17 | BlockExplorerURL string 18 | // MinWallets is the minimum number of voting wallets required for a vspd 19 | // deployment on this network. vspd will log an error and refuse to start if 20 | // fewer wallets are configured. 21 | MinWallets int 22 | // DCP0005Height is the activation height of DCP-0005 block header 23 | // commitments agenda on this network. 24 | DCP0005Height int64 25 | // DCP0010Height is the activation height of DCP-0010 change PoW/PoS subsidy 26 | // split agenda on this network. 27 | DCP0010Height int64 28 | // DCP0012Height is the activation height of DCP-0012 change PoW/PoS subsidy 29 | // split R2 agenda on this network. 30 | DCP0012Height int64 31 | } 32 | 33 | var MainNet = Network{ 34 | Params: chaincfg.MainNetParams(), 35 | DcrdRPCServerPort: "9109", 36 | WalletRPCServerPort: "9110", 37 | BlockExplorerURL: "https://dcrdata.decred.org", 38 | MinWallets: 3, 39 | // DCP0005Height on mainnet is block 40 | // 000000000000000010815bed2c4dc431c34a859f4fc70774223dde788e95a01e. 41 | DCP0005Height: 431488, 42 | // DCP0010Height on mainnet is block 43 | // 00000000000000002f4c6aaf0e9cb4d5a74c238d9bf8b8909e2372776c7c214c. 44 | DCP0010Height: 657280, 45 | // DCP0012Height on mainnet is block 46 | // 071683030010299ab13f139df59dc98d637957b766e47f8da6dd5ac762f1e8c7. 47 | DCP0012Height: 794368, 48 | } 49 | 50 | var TestNet3 = Network{ 51 | Params: chaincfg.TestNet3Params(), 52 | DcrdRPCServerPort: "19109", 53 | WalletRPCServerPort: "19110", 54 | BlockExplorerURL: "https://testnet.dcrdata.org", 55 | MinWallets: 1, 56 | // DCP0005Height on testnet3 is block 57 | // 0000003e54421d585f4a609393a8694509af98f62b8449f245b09fe1389f8f77. 58 | DCP0005Height: 323328, 59 | // DCP0010Height on testnet3 is block 60 | // 000000000000c7fd75f2234bbff6bb81de3a9ebbd2fdd383ae3dbc6205ffe4ff. 61 | DCP0010Height: 877728, 62 | // DCP0012Height on testnet3 is block 63 | // c7da7b548a2a9463dc97adb48433c4ffff18c3873f7e2ae99338a990dae039f0. 64 | DCP0012Height: 1170048, 65 | } 66 | 67 | var SimNet = Network{ 68 | Params: chaincfg.SimNetParams(), 69 | DcrdRPCServerPort: "19556", 70 | WalletRPCServerPort: "19557", 71 | BlockExplorerURL: "...", 72 | MinWallets: 1, 73 | // DCP0005Height on simnet is 1 because the agenda will always be active. 74 | DCP0005Height: 1, 75 | // DCP0010Height on simnet is 1 because the agenda will always be active. 76 | DCP0010Height: 1, 77 | // DCP0012Height on simnet is 1 because the agenda will always be active. 78 | DCP0012Height: 1, 79 | } 80 | 81 | func NetworkFromName(name string) (*Network, error) { 82 | switch name { 83 | case "mainnet": 84 | return &MainNet, nil 85 | case "testnet": 86 | return &TestNet3, nil 87 | case "simnet": 88 | return &SimNet, nil 89 | default: 90 | return nil, fmt.Errorf("%q is not a supported network", name) 91 | } 92 | } 93 | 94 | // DCP5Active returns true if the DCP-0005 block header commitments agenda is 95 | // active on this network at the provided height, otherwise false. 96 | func (n *Network) DCP5Active(height int64) bool { 97 | return height >= n.DCP0005Height 98 | } 99 | 100 | // DCP10Active returns true if the DCP-0010 change PoW/PoS subsidy split agenda 101 | // is active on this network at the provided height, otherwise false. 102 | func (n *Network) DCP10Active(height int64) bool { 103 | return height >= n.DCP0010Height 104 | } 105 | 106 | // DCP12Active returns true if the DCP-0012 change PoW/PoS subsidy split R2 107 | // agenda is active on this network at the provided height, otherwise false. 108 | func (n *Network) DCP12Active(height int64) bool { 109 | return height >= n.DCP0012Height 110 | } 111 | 112 | // CurrentVoteVersion returns the most recent version in the current networks 113 | // consensus agenda deployments. 114 | func (n *Network) CurrentVoteVersion() uint32 { 115 | var latestVersion uint32 116 | for version := range n.Deployments { 117 | if latestVersion < version { 118 | latestVersion = version 119 | } 120 | } 121 | return latestVersion 122 | } 123 | -------------------------------------------------------------------------------- /internal/webapi/setaltsignaddr.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021-2024 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package webapi 6 | 7 | import ( 8 | "time" 9 | 10 | dcrdtypes "github.com/decred/dcrd/rpc/jsonrpc/types/v4" 11 | "github.com/decred/dcrd/txscript/v4/stdaddr" 12 | "github.com/decred/vspd/database" 13 | "github.com/decred/vspd/rpc" 14 | "github.com/decred/vspd/types/v3" 15 | "github.com/gin-gonic/gin" 16 | "github.com/gin-gonic/gin/binding" 17 | ) 18 | 19 | // Ensure that Node is satisfied by *rpc.DcrdRPC. 20 | var _ node = (*rpc.DcrdRPC)(nil) 21 | 22 | // node is satisfied by *rpc.DcrdRPC and retrieves data from the blockchain. 23 | type node interface { 24 | ExistsLiveTicket(ticketHash string) (bool, error) 25 | GetRawTransaction(txHash string) (*dcrdtypes.TxRawResult, error) 26 | } 27 | 28 | // setAltSignAddr is the handler for "POST /api/v3/setaltsignaddr". 29 | func (w *WebAPI) setAltSignAddr(c *gin.Context) { 30 | 31 | const funcName = "setAltSignAddr" 32 | 33 | // Get values which have been added to context by middleware. 34 | dcrdClient := c.MustGet(dcrdKey).(node) 35 | dcrdErr := c.MustGet(dcrdErrorKey) 36 | if dcrdErr != nil { 37 | w.log.Errorf("%s: %v", funcName, dcrdErr.(error)) 38 | w.sendError(types.ErrInternalError, c) 39 | return 40 | } 41 | reqBytes := c.MustGet(requestBytesKey).([]byte) 42 | 43 | var request types.SetAltSignAddrRequest 44 | if err := binding.JSON.BindBody(reqBytes, &request); err != nil { 45 | w.log.Warnf("%s: Bad request (clientIP=%s): %v", funcName, c.ClientIP(), err) 46 | w.sendErrorWithMsg(err.Error(), types.ErrBadRequest, c) 47 | return 48 | } 49 | 50 | altSignAddr, ticketHash := request.AltSignAddress, request.TicketHash 51 | 52 | currentData, err := w.db.AltSignAddrData(ticketHash) 53 | if err != nil { 54 | w.log.Errorf("%s: db.AltSignAddrData (ticketHash=%s): %v", funcName, ticketHash, err) 55 | w.sendError(types.ErrInternalError, c) 56 | return 57 | } 58 | if currentData != nil { 59 | const msg = "alternate sign address data already exists" 60 | w.log.Warnf("%s: %s (ticketHash=%s)", funcName, msg, ticketHash) 61 | w.sendErrorWithMsg(msg, types.ErrBadRequest, c) 62 | return 63 | 64 | } 65 | 66 | // Fail fast if the pubkey doesn't decode properly. 67 | addr, err := stdaddr.DecodeAddressV0(altSignAddr, w.cfg.Network) 68 | if err != nil { 69 | w.log.Warnf("%s: Alt sign address cannot be decoded (clientIP=%s): %v", funcName, c.ClientIP(), err) 70 | w.sendErrorWithMsg(err.Error(), types.ErrBadRequest, c) 71 | return 72 | } 73 | if _, ok := addr.(*stdaddr.AddressPubKeyHashEcdsaSecp256k1V0); !ok { 74 | w.log.Warnf("%s: Alt sign address is unexpected type (clientIP=%s, type=%T)", funcName, c.ClientIP(), addr) 75 | w.sendErrorWithMsg("wrong type for alternate signing address", types.ErrBadRequest, c) 76 | return 77 | } 78 | 79 | // Get ticket details. 80 | rawTicket, err := dcrdClient.GetRawTransaction(ticketHash) 81 | if err != nil { 82 | w.log.Errorf("%s: dcrd.GetRawTransaction for ticket failed (ticketHash=%s): %v", funcName, ticketHash, err) 83 | w.sendError(types.ErrInternalError, c) 84 | return 85 | } 86 | 87 | // Ensure this ticket is eligible to vote at some point in the future. 88 | canVote, err := canTicketVote(rawTicket, dcrdClient, w.cfg.Network) 89 | if err != nil { 90 | w.log.Errorf("%s: canTicketVote error (ticketHash=%s): %v", funcName, ticketHash, err) 91 | w.sendError(types.ErrInternalError, c) 92 | return 93 | } 94 | if !canVote { 95 | w.log.Warnf("%s: unvotable ticket (clientIP=%s, ticketHash=%s)", 96 | funcName, c.ClientIP(), ticketHash) 97 | w.sendError(types.ErrTicketCannotVote, c) 98 | return 99 | } 100 | 101 | // Send success response to client. 102 | resp, respSig := w.sendJSONResponse(types.SetAltSignAddrResponse{ 103 | Timestamp: time.Now().Unix(), 104 | Request: reqBytes, 105 | }, c) 106 | 107 | data := &database.AltSignAddrData{ 108 | AltSignAddr: altSignAddr, 109 | Req: string(reqBytes), 110 | ReqSig: c.GetHeader("VSP-Client-Signature"), 111 | Resp: resp, 112 | RespSig: respSig, 113 | } 114 | 115 | err = w.db.InsertAltSignAddr(ticketHash, data) 116 | if err != nil { 117 | w.log.Errorf("%s: db.InsertAltSignAddr error (ticketHash=%s): %v", 118 | funcName, ticketHash, err) 119 | return 120 | } 121 | 122 | w.log.Debugf("%s: New alt sign address set for ticket: (ticketHash=%s)", funcName, ticketHash) 123 | } 124 | -------------------------------------------------------------------------------- /internal/webapi/templates/header.html: -------------------------------------------------------------------------------- 1 | {{define "header"}} 2 | 3 | 4 | 5 | 6 | 7 | 8 | Decred VSP - {{ .WebApiCfg.Designation }} 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 |
47 | 48 | 67 | 68 | {{ if .WebApiCfg.Debug }} 69 |
70 |
71 | Web server is running in debug mode - don't do this in production! 72 |
73 |
74 | {{ end }} 75 | 76 | {{end}} 77 | -------------------------------------------------------------------------------- /docs/release-notes/release-notes.1.4.0.md: -------------------------------------------------------------------------------- 1 | # vspd 1.4.0 2 | 3 | vspd v1.4.0 contains all development work completed since v1.3.2 (November 2023). 4 | All commits included in this release can be viewed 5 | [on GitHub](https://github.com/decred/vspd/compare/release-v1.3.2...release-v1.4.0). 6 | 7 | ## Downgrade Warning 8 | 9 | This release contains a backwards incompatible database upgrade. 10 | The new database format is not compatible with previous versions of the vspd 11 | software, and there is no code to downgrade the database back to the previous 12 | version. 13 | 14 | Making a copy of the database backup before running the upgrade is suggested 15 | in order to enable rolling back to a previous version of the software if required. 16 | 17 | ## Dependencies 18 | 19 | vspd 1.4.0 must be built with go 1.24 or later, and requires: 20 | 21 | - dcrd 2.1.0 22 | - dcrwallet 2.1.0 23 | 24 | Always use release versions of all binaries when deploying vspd to production. 25 | Neither vspd nor its dependencies should be built from master when handling 26 | mainnet tickets. 27 | 28 | ## Recommended Upgrade Procedure 29 | 30 | The upgrade procedure below includes vspd downtime, during which clients will 31 | not be able to register new tickets, check their ticket status, or update their 32 | voting preferences. You may wish to put up a temporary maintenance webpage or 33 | announce downtime in public channels. Voting on tickets already registered with 34 | the VSP will not be interrupted. 35 | 36 | 1. Build vspd from the `release-v1.4.0` tag, and build dcrwallet and dcrd from 37 | their `release-v2.1.0` tags. 38 | 1. Stop vspd. 39 | 1. **Make a backup of the vspd database file in case rollback is required.** 40 | 1. Stop the instance of dcrd running on the vspd server. 41 | 1. Install new dcrd binary on the vspd server and start it to begin any required 42 | database upgrades. You can proceed with the following steps while the 43 | upgrades run. 44 | 1. Upgrade voting wallets one by one so at least two wallets remain online for 45 | voting at all times. On each server: 46 | 1. Stop dcrwallet. 47 | 1. Stop dcrd. 48 | 1. Install new dcrd binary and start. 49 | 1. Wait for any dcrd database upgrades to complete. 50 | 1. Check dcrd log file for warnings or errors. 51 | 1. Install new dcrwallet binary and start. 52 | 1. Wait for any dcrwallet database upgrades to complete. 53 | 1. Check dcrwallet log file for warnings or errors. 54 | 1. Ensure dcrd on the vspd server has completed all database upgrades. 55 | 1. Check dcrd log file for warnings or errors. 56 | 1. Install new vspd binary and start it. 57 | 1. Check vspd log file for warnings or errors. 58 | 1. Log in to the admin webpage and check the VSP Status tab for any issues. 59 | 60 | ## Notable Changes 61 | 62 | - A new executable named vspadmin has been added to the repository. 63 | 64 | vspadmin is a tool to perform various VSP administration tasks such as 65 | initializing new databases and creating default config files for fresh vspd 66 | deployments. It also enables operators of existing VSPs to change the extended 67 | public keys (xpub) used for collecting fees, something which was previously 68 | not possible. 69 | 70 | Full documentation for vspadmin can be found 71 | [on GitHub](https://github.com/decred/vspd/blob/master/cmd/vspadmin/README.md). 72 | 73 | - The current and any historic fee xpub keys are listed on a new tab in the admin 74 | page. 75 | 76 | - Fee calculation now takes the new block reward subsidy split from the activation 77 | of [DCP-0012](https://github.com/decred/dcps/blob/master/dcp-0012/dcp-0012.mediawiki) 78 | into consideration. In practice, this means that VSPs will begin charging 79 | marginally higher fees. 80 | 81 | ### Config Changes 82 | 83 | - The vspd flag `--feexpub` is now deprecated and does nothing. The equivalent 84 | functionality has been moved into the `createdatabase` command of the new 85 | vspadmin executable. 86 | 87 | - The vspd flag `--configfile` is now deprecated and does nothing. It is still 88 | possible to run vspd with config in a non-default location using the 89 | `--homedir` flag. 90 | 91 | ### API changes 92 | 93 | - After being deprecated in release 1.3.0, the revoked ticket count has now been 94 | removed from `/vspinfo`. The number of revoked tickets can be calculated 95 | by adding the number of missed and expired tickets. 96 | 97 | ### Bug Fixes 98 | 99 | - Don't run upgrades unnecessarily on brand new databases 100 | ([#477](https://github.com/decred/vspd/pull/477)). 101 | - Don't initialize databases with private keys, only public 102 | ([#478](https://github.com/decred/vspd/pull/478)). 103 | - Various minor GUI improvements and bugfixes 104 | ([#495](https://github.com/decred/vspd/pull/495)). 105 | -------------------------------------------------------------------------------- /internal/webapi/helpers_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020-2023 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package webapi 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/decred/vspd/internal/config" 11 | ) 12 | 13 | func TestIsValidVoteChoices(t *testing.T) { 14 | 15 | // Mainnet vote version 4 contains 2 agendas - sdiffalgorithm and lnsupport. 16 | // Both agendas have vote choices yes/no/abstain. 17 | voteVersion := uint32(4) 18 | network := config.MainNet 19 | 20 | var tests = []struct { 21 | voteChoices map[string]string 22 | valid bool 23 | }{ 24 | // Empty vote choices are allowed. 25 | {map[string]string{}, true}, 26 | 27 | // Valid agenda, valid vote choice. 28 | {map[string]string{"lnsupport": "yes"}, true}, 29 | {map[string]string{"sdiffalgorithm": "no", "lnsupport": "yes"}, true}, 30 | 31 | // Invalid agenda, valid vote choice. 32 | {map[string]string{"": "yes"}, false}, 33 | {map[string]string{"Fake agenda": "yes"}, false}, 34 | 35 | // Valid agenda, invalid vote choice. 36 | {map[string]string{"lnsupport": "1234"}, false}, 37 | {map[string]string{"sdiffalgorithm": ""}, false}, 38 | 39 | // One valid choice, one invalid choice. 40 | {map[string]string{"sdiffalgorithm": "no", "lnsupport": "1234"}, false}, 41 | {map[string]string{"sdiffalgorithm": "1234", "lnsupport": "no"}, false}, 42 | 43 | // One valid agenda, one invalid agenda. 44 | {map[string]string{"fake": "abstain", "lnsupport": "no"}, false}, 45 | {map[string]string{"sdiffalgorithm": "abstain", "": "no"}, false}, 46 | } 47 | 48 | for _, test := range tests { 49 | err := validConsensusVoteChoices(&network, voteVersion, test.voteChoices) 50 | if (err == nil) != test.valid { 51 | t.Fatalf("isValidVoteChoices failed for votechoices '%v': %v", 52 | test.voteChoices, err) 53 | } 54 | } 55 | } 56 | 57 | func TestIsValidTSpendPolicy(t *testing.T) { 58 | 59 | // A valid tspend hash is 32 bytes (64 characters). 60 | const validHash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" 61 | const anotherValidHash = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" 62 | 63 | var tests = []struct { 64 | tspendPolicy map[string]string 65 | valid bool 66 | }{ 67 | // Empty vote choices are allowed. 68 | {map[string]string{}, true}, 69 | 70 | // Valid tspend hash, valid vote choice. 71 | {map[string]string{validHash: "yes"}, true}, 72 | {map[string]string{validHash: ""}, true}, 73 | {map[string]string{validHash: "no", anotherValidHash: "yes"}, true}, 74 | 75 | // Invalid tspend hash. 76 | {map[string]string{"": "yes"}, false}, 77 | {map[string]string{"a": "yes"}, false}, 78 | {map[string]string{"non hex characters": "yes"}, false}, 79 | {map[string]string{validHash + "a": "yes"}, false}, 80 | 81 | // Valid tspend hash, invalid vote choice. 82 | {map[string]string{validHash: "1234"}, false}, 83 | 84 | // // One valid choice, one invalid choice. 85 | {map[string]string{validHash: "no", anotherValidHash: "1234"}, false}, 86 | {map[string]string{validHash: "1234", anotherValidHash: "no"}, false}, 87 | 88 | // One valid tspend hash, one invalid tspend hash. 89 | {map[string]string{"fake": "abstain", anotherValidHash: "no"}, false}, 90 | {map[string]string{validHash: "abstain", "": "no"}, false}, 91 | } 92 | 93 | for _, test := range tests { 94 | err := validTSpendPolicy(test.tspendPolicy) 95 | if (err == nil) != test.valid { 96 | t.Fatalf("validTSpendPolicy failed for policy '%v': %v", 97 | test.tspendPolicy, err) 98 | } 99 | } 100 | } 101 | 102 | func TestIsValidTreasuryPolicy(t *testing.T) { 103 | 104 | // A valid treasury key is 33 bytes (66 characters). 105 | const validKey = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" 106 | const anotherValidKey = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" 107 | 108 | var tests = []struct { 109 | treasuryPolicy map[string]string 110 | valid bool 111 | }{ 112 | // Empty vote choices are allowed. 113 | {map[string]string{}, true}, 114 | 115 | // Valid treasury key, valid vote choice. 116 | {map[string]string{validKey: "yes"}, true}, 117 | {map[string]string{validKey: ""}, true}, 118 | {map[string]string{validKey: "no", anotherValidKey: "yes"}, true}, 119 | 120 | // Invalid treasury key. 121 | {map[string]string{"": "yes"}, false}, 122 | {map[string]string{"a": "yes"}, false}, 123 | {map[string]string{"non hex characters": "yes"}, false}, 124 | {map[string]string{validKey + "a": "yes"}, false}, 125 | 126 | // Valid treasury key, invalid vote choice. 127 | {map[string]string{validKey: "1234"}, false}, 128 | 129 | // // One valid choice, one invalid choice. 130 | {map[string]string{validKey: "no", anotherValidKey: "1234"}, false}, 131 | {map[string]string{validKey: "1234", anotherValidKey: "no"}, false}, 132 | 133 | // One valid treasury key, one invalid treasury key. 134 | {map[string]string{"fake": "abstain", anotherValidKey: "no"}, false}, 135 | {map[string]string{validKey: "abstain", "": "no"}, false}, 136 | } 137 | 138 | for _, test := range tests { 139 | err := validTreasuryPolicy(test.treasuryPolicy) 140 | if (err == nil) != test.valid { 141 | t.Fatalf("validTreasuryPolicy failed for policy '%v': %v", 142 | test.treasuryPolicy, err) 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /cmd/v3tool/dcrwallet.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021-2025 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "context" 9 | "crypto/tls" 10 | "encoding/base64" 11 | "encoding/hex" 12 | "errors" 13 | "fmt" 14 | "strings" 15 | 16 | wallettypes "decred.org/dcrwallet/v5/rpc/jsonrpc/types" 17 | "github.com/decred/dcrd/blockchain/stake/v5" 18 | "github.com/decred/dcrd/chaincfg/v3" 19 | "github.com/decred/dcrd/dcrutil/v4" 20 | dcrdtypes "github.com/decred/dcrd/rpc/jsonrpc/types/v4" 21 | "github.com/decred/dcrd/txscript/v4/stdaddr" 22 | "github.com/decred/dcrd/txscript/v4/stdscript" 23 | "github.com/decred/dcrd/wire" 24 | "github.com/jrick/wsrpc/v2" 25 | ) 26 | 27 | type dcrwallet struct { 28 | *wsrpc.Client 29 | } 30 | 31 | func newWalletRPC(ctx context.Context, rpcURL, rpcUser, rpcPass string) (*dcrwallet, error) { 32 | tlsOpt := wsrpc.WithTLSConfig(&tls.Config{ 33 | InsecureSkipVerify: true, 34 | }) 35 | authOpt := wsrpc.WithBasicAuth(rpcUser, rpcPass) 36 | rpc, err := wsrpc.Dial(ctx, rpcURL, tlsOpt, authOpt) 37 | if err != nil { 38 | return nil, err 39 | } 40 | return &dcrwallet{rpc}, nil 41 | } 42 | 43 | func (w *dcrwallet) createFeeTx(ctx context.Context, feeAddress string, fee int64) (string, error) { 44 | amounts := make(map[string]float64) 45 | amounts[feeAddress] = dcrutil.Amount(fee).ToCoin() 46 | 47 | var msgtxstr string 48 | err := w.Call(ctx, "createrawtransaction", &msgtxstr, nil, amounts) 49 | if err != nil { 50 | return "", err 51 | } 52 | 53 | zero := int32(0) 54 | opt := wallettypes.FundRawTransactionOptions{ 55 | ConfTarget: &zero, 56 | } 57 | var fundTx wallettypes.FundRawTransactionResult 58 | err = w.Call(ctx, "fundrawtransaction", &fundTx, msgtxstr, "default", &opt) 59 | if err != nil { 60 | return "", err 61 | } 62 | 63 | tx := wire.NewMsgTx() 64 | err = tx.Deserialize(hex.NewDecoder(strings.NewReader(fundTx.Hex))) 65 | if err != nil { 66 | return "", err 67 | } 68 | 69 | transactions := make([]dcrdtypes.TransactionInput, 0) 70 | 71 | for _, v := range tx.TxIn { 72 | transactions = append(transactions, dcrdtypes.TransactionInput{ 73 | Txid: v.PreviousOutPoint.Hash.String(), 74 | Vout: v.PreviousOutPoint.Index, 75 | }) 76 | } 77 | 78 | var locked bool 79 | const unlock = false 80 | err = w.Call(ctx, "lockunspent", &locked, unlock, transactions) 81 | if err != nil { 82 | return "", err 83 | } 84 | 85 | if !locked { 86 | return "", errors.New("unspent output not locked") 87 | } 88 | 89 | var signedTx wallettypes.SignRawTransactionResult 90 | err = w.Call(ctx, "signrawtransaction", &signedTx, fundTx.Hex) 91 | if err != nil { 92 | return "", err 93 | } 94 | if !signedTx.Complete { 95 | return "", fmt.Errorf("not all signed") 96 | } 97 | return signedTx.Hex, nil 98 | } 99 | 100 | func (w *dcrwallet) SignMessage(ctx context.Context, msg string, commitmentAddr stdaddr.Address) ([]byte, error) { 101 | var signature string 102 | err := w.Call(ctx, "signmessage", &signature, commitmentAddr.String(), msg) 103 | if err != nil { 104 | return nil, err 105 | } 106 | 107 | return base64.StdEncoding.DecodeString(signature) 108 | } 109 | 110 | func (w *dcrwallet) dumpPrivKey(ctx context.Context, addr stdaddr.Address) (string, error) { 111 | var privKeyStr string 112 | err := w.Call(ctx, "dumpprivkey", &privKeyStr, addr.String()) 113 | if err != nil { 114 | return "", err 115 | } 116 | return privKeyStr, nil 117 | } 118 | 119 | func (w *dcrwallet) getTickets(ctx context.Context) (*wallettypes.GetTicketsResult, error) { 120 | var tickets wallettypes.GetTicketsResult 121 | const includeImmature = true 122 | err := w.Call(ctx, "gettickets", &tickets, includeImmature) 123 | if err != nil { 124 | return nil, err 125 | } 126 | return &tickets, nil 127 | } 128 | 129 | // getTicketDetails returns the ticket hex, privkey for voting, and the 130 | // commitment address. 131 | func (w *dcrwallet) getTicketDetails(ctx context.Context, ticketHash string) (string, string, stdaddr.Address, error) { 132 | var getTransactionResult wallettypes.GetTransactionResult 133 | err := w.Call(ctx, "gettransaction", &getTransactionResult, ticketHash, false) 134 | if err != nil { 135 | fmt.Printf("gettransaction: %v\n", err) 136 | return "", "", nil, err 137 | } 138 | 139 | msgTx := wire.NewMsgTx() 140 | if err = msgTx.Deserialize(hex.NewDecoder(strings.NewReader(getTransactionResult.Hex))); err != nil { 141 | return "", "", nil, err 142 | } 143 | if len(msgTx.TxOut) < 2 { 144 | return "", "", nil, errors.New("msgTx.TxOut < 2") 145 | } 146 | 147 | const scriptVersion = 0 148 | scriptType, submissionAddr := stdscript.ExtractAddrs(scriptVersion, 149 | msgTx.TxOut[0].PkScript, chaincfg.TestNet3Params()) 150 | if scriptType == stdscript.STNonStandard { 151 | return "", "", nil, fmt.Errorf("invalid script version %d", scriptVersion) 152 | } 153 | if len(submissionAddr) != 1 { 154 | return "", "", nil, errors.New("submissionAddr != 1") 155 | } 156 | 157 | addr, err := stake.AddrFromSStxPkScrCommitment(msgTx.TxOut[1].PkScript, 158 | chaincfg.TestNet3Params()) 159 | if err != nil { 160 | return "", "", nil, err 161 | } 162 | 163 | privKeyStr, err := w.dumpPrivKey(ctx, submissionAddr[0]) 164 | if err != nil { 165 | return "", "", nil, err 166 | } 167 | 168 | return getTransactionResult.Hex, privKeyStr, addr, nil 169 | } 170 | -------------------------------------------------------------------------------- /harness.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Copyright (c) 2020-2024 The Decred developers 4 | # Use of this source code is governed by an ISC 5 | # license that can be found in the LICENSE file. 6 | # 7 | # Tmux script that sets up a testnet vspd deployment with multiple voting wallets. 8 | # 9 | # To use the script simply run `./harness.sh` from the repo root. 10 | # 11 | # By default, the harness script will use `/tmp/vspd-harness` as a working 12 | # directory. This can be changed using the `-r` flag, eg: 13 | # 14 | # ./harness.sh -r /my/harness/path 15 | # 16 | # The harness script makes a few assumptions about the system it is running on: 17 | # - tmux is installed 18 | # - dcrd, dcrwallet and vspd are available on $PATH 19 | # - Decred testnet chain is already downloaded and sync'd 20 | # - dcrd transaction index is already built 21 | # - The following files exist: 22 | # - ${HOME}/.dcrd/rpc.cert 23 | # - ${HOME}/.dcrd/rpc.key 24 | # - ${HOME}/.dcrwallet/rpc.cert 25 | # - ${HOME}/.dcrwallet/rpc.key 26 | 27 | set -e 28 | 29 | TMUX_SESSION="vspd-harness" 30 | RPC_USER="user" 31 | RPC_PASS="pass" 32 | NUMBER_OF_WALLETS=3 33 | 34 | DCRD_RPC_CERT="${HOME}/.dcrd/rpc.cert" 35 | DCRD_RPC_KEY="${HOME}/.dcrd/rpc.key" 36 | 37 | WALLET_PASS="12345" 38 | WALLET_RPC_CERT="${HOME}/.dcrwallet/rpc.cert" 39 | WALLET_RPC_KEY="${HOME}/.dcrwallet/rpc.key" 40 | 41 | VSPD_FEE_XPUB="tpubVppjaMjp8GEWzpMGHdXNhkjqof8baKGkUzneNEiocnnjnjY9hQPe6mxzZQyzyKYS3u5yxLp8KrJvibqDzc75RGqzkv2JMPYDXmCRR1a39jg" 42 | 43 | HARNESS_ROOT=/tmp/vspd-harness 44 | while getopts r: flag 45 | do 46 | case "${flag}" in 47 | r) HARNESS_ROOT=${OPTARG}; 48 | esac 49 | done 50 | 51 | if [ -d "${HARNESS_ROOT}" ]; then 52 | while true; do 53 | read -p "Wipe existing harness dir? " yn 54 | case $yn in 55 | 56 | [Yy]* ) rm -R "${HARNESS_ROOT}"; break;; 57 | [Nn]* ) break;; 58 | * ) echo "Please answer yes or no.";; 59 | esac 60 | done 61 | fi 62 | 63 | tmux new-session -d -s $TMUX_SESSION 64 | 65 | ################################################# 66 | # Setup dcrd. 67 | ################################################# 68 | 69 | tmux rename-window -t $TMUX_SESSION 'dcrd' 70 | 71 | echo "Writing config for dcrd" 72 | mkdir -p "${HARNESS_ROOT}/dcrd" 73 | cat > "${HARNESS_ROOT}/dcrd/dcrd.conf" < "${HARNESS_ROOT}/dcrwallet-${i}/dcrwallet.conf" < "${HARNESS_ROOT}/vspd/vspd.conf" < int64(uint32(network.TicketMaturity)+network.TicketExpiry)+1 { 170 | return false, nil 171 | } 172 | 173 | // If ticket is currently immature (or in the mempool), it will be able to 174 | // vote in future. 175 | if rawTx.Confirmations <= int64(network.TicketMaturity) { 176 | return true, nil 177 | } 178 | 179 | // If ticket is currently live, it will be able to vote in future. 180 | live, err := dcrdClient.ExistsLiveTicket(rawTx.Txid) 181 | if err != nil { 182 | return false, fmt.Errorf("dcrd.ExistsLiveTicket error: %w", err) 183 | } 184 | 185 | return live, nil 186 | } 187 | -------------------------------------------------------------------------------- /client/client_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2022-2024 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package client 6 | 7 | import ( 8 | "context" 9 | "crypto/ed25519" 10 | "encoding/base64" 11 | "errors" 12 | "net/http" 13 | "net/http/httptest" 14 | "testing" 15 | 16 | "github.com/decred/slog" 17 | "github.com/decred/vspd/types/v3" 18 | ) 19 | 20 | // TestErrorDetails ensures errors returned by client.do contain adequate 21 | // information for debugging (HTTP status and response body). 22 | func TestErrorDetails(t *testing.T) { 23 | 24 | tests := map[string]struct { 25 | respHTTPStatus int 26 | respBodyBytes []byte 27 | expectedErr string 28 | vspdError bool 29 | vspdErrCode types.ErrorCode 30 | }{ 31 | "500, vspd error (generic bad request)": { 32 | respHTTPStatus: 500, 33 | respBodyBytes: []byte(`{"code": 0, "message": "bad request"}`), 34 | expectedErr: `bad request`, 35 | vspdError: true, 36 | vspdErrCode: types.ErrBadRequest, 37 | }, 38 | "500, vspd error (generic internal error)": { 39 | respHTTPStatus: 500, 40 | respBodyBytes: []byte(`{"code": 1, "message": "something terrible happened"}`), 41 | expectedErr: `something terrible happened`, 42 | vspdError: true, 43 | vspdErrCode: types.ErrInternalError, 44 | }, 45 | "428, vspd error (cannot broadcast fee)": { 46 | respHTTPStatus: 428, 47 | respBodyBytes: []byte(`{"code": 16, "message": "fee transaction could not be broadcast due to unknown outputs"}`), 48 | expectedErr: `fee transaction could not be broadcast due to unknown outputs`, 49 | vspdError: true, 50 | vspdErrCode: types.ErrCannotBroadcastFeeUnknownOutputs, 51 | }, 52 | "500, no body": { 53 | respHTTPStatus: 500, 54 | respBodyBytes: nil, 55 | expectedErr: `http status 500 (Internal Server Error) with no body`, 56 | vspdError: false, 57 | }, 58 | "500, non vspd error": { 59 | respHTTPStatus: 500, 60 | respBodyBytes: []byte(`an error occurred`), 61 | expectedErr: `http status 500 (Internal Server Error) with body "an error occurred"`, 62 | vspdError: false, 63 | }, 64 | "500, non vspd error (json)": { 65 | respHTTPStatus: 500, 66 | respBodyBytes: []byte(`{"some": "json"}`), 67 | expectedErr: `http status 500 (Internal Server Error) with body "{\"some\": \"json\"}"`, 68 | vspdError: false, 69 | }, 70 | } 71 | 72 | for testName, testData := range tests { 73 | t.Run(testName, func(t *testing.T) { 74 | 75 | testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, _ *http.Request) { 76 | res.WriteHeader(testData.respHTTPStatus) 77 | _, err := res.Write(testData.respBodyBytes) 78 | if err != nil { 79 | t.Fatalf("writing response body failed: %v", err) 80 | } 81 | })) 82 | 83 | client := Client{ 84 | URL: testServer.URL, 85 | PubKey: []byte("fake pubkey"), 86 | Log: slog.Disabled, 87 | } 88 | 89 | var resp any 90 | err := client.do(context.TODO(), http.MethodGet, "", nil, &resp, nil) 91 | 92 | testServer.Close() 93 | 94 | if err == nil { 95 | t.Fatalf("client.do did not return an error") 96 | } 97 | 98 | if err.Error() != testData.expectedErr { 99 | t.Fatalf("client.do returned incorrect error, expected %q, got %q", 100 | testData.expectedErr, err.Error()) 101 | } 102 | 103 | if testData.vspdError { 104 | // Error should be unwrappable as a vspd error response. 105 | var e types.ErrorResponse 106 | if !errors.As(err, &e) { 107 | t.Fatal("unable to unwrap vspd error") 108 | } 109 | 110 | if e.Code != testData.vspdErrCode { 111 | t.Fatalf("incorrect vspd error code, expected %d, got %d", 112 | testData.vspdErrCode, e.Code) 113 | } 114 | } 115 | 116 | }) 117 | } 118 | } 119 | 120 | // TestSignatureValidation ensures that responses with invalid signatures are 121 | // flagged. 122 | func TestSignatureValidation(t *testing.T) { 123 | 124 | // Generate some test data for the valid signature case. 125 | privKey := ed25519.NewKeyFromSeed([]byte("00000000000000000000000000000000")) 126 | pubKey, _ := privKey.Public().(ed25519.PublicKey) 127 | emptyJSON := []byte("{}") 128 | validSig := base64.StdEncoding.EncodeToString(ed25519.Sign(privKey, emptyJSON)) 129 | 130 | tests := map[string]struct { 131 | responseSig string 132 | expectErr bool 133 | expectErrStr string 134 | }{ 135 | "valid signature": { 136 | responseSig: validSig, 137 | expectErr: false, 138 | }, 139 | "invalid signature": { 140 | responseSig: "1234", 141 | expectErr: true, 142 | expectErrStr: "authenticate server response: invalid signature", 143 | }, 144 | "no signature": { 145 | responseSig: "", 146 | expectErr: true, 147 | expectErrStr: "authenticate server response: no signature provided", 148 | }, 149 | "failed to decode signature": { 150 | responseSig: "0xp", 151 | expectErr: true, 152 | expectErrStr: "authenticate server response: failed to decode signature: illegal base64 data at input byte 0", 153 | }, 154 | } 155 | 156 | for testName, testData := range tests { 157 | t.Run(testName, func(t *testing.T) { 158 | 159 | testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, _ *http.Request) { 160 | res.Header().Add("VSP-Server-Signature", testData.responseSig) 161 | res.WriteHeader(http.StatusOK) 162 | _, err := res.Write(emptyJSON) 163 | if err != nil { 164 | t.Fatalf("writing response body failed: %v", err) 165 | } 166 | })) 167 | 168 | client := Client{ 169 | URL: testServer.URL, 170 | PubKey: pubKey, 171 | Log: slog.Disabled, 172 | } 173 | 174 | var resp any 175 | err := client.do(context.TODO(), http.MethodGet, "", nil, &resp, nil) 176 | 177 | testServer.Close() 178 | 179 | if testData.expectErr { 180 | if err.Error() != testData.expectErrStr { 181 | t.Fatalf("client.do returned incorrect error, expected %q, got %q", 182 | testData.expectErrStr, err.Error()) 183 | } 184 | } else { 185 | if err != nil { 186 | t.Fatalf("client.do returned unexpected error: %v", err) 187 | } 188 | } 189 | 190 | }) 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /cmd/vspd/main.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020-2024 The Decred developers 2 | // Use of this source code is governed by an ISC 3 | // license that can be found in the LICENSE file. 4 | 5 | package main 6 | 7 | import ( 8 | "fmt" 9 | "os" 10 | "runtime" 11 | "sync" 12 | "time" 13 | 14 | "github.com/decred/dcrd/wire" 15 | "github.com/decred/slog" 16 | "github.com/decred/vspd/database" 17 | "github.com/decred/vspd/internal/config" 18 | "github.com/decred/vspd/internal/signal" 19 | "github.com/decred/vspd/internal/version" 20 | "github.com/decred/vspd/internal/vspd" 21 | "github.com/decred/vspd/internal/webapi" 22 | "github.com/decred/vspd/rpc" 23 | ) 24 | 25 | const ( 26 | // maxVoteChangeRecords defines how many vote change records will be stored 27 | // for each ticket. The limit is in place to mitigate DoS attacks on server 28 | // storage space. When storing a new record breaches this limit, the oldest 29 | // record in the database is deleted. 30 | maxVoteChangeRecords = 10 31 | ) 32 | 33 | func main() { 34 | os.Exit(run()) 35 | } 36 | 37 | // initLogging uses the provided vspd config to create a logging backend, and 38 | // returns a function which can be used to create ready-to-use subsystem 39 | // loggers. 40 | func initLogging(cfg *vspd.Config) (func(subsystem string) slog.Logger, error) { 41 | backend, err := newLogBackend(cfg.LogDir(), "vspd", cfg.MaxLogSize, cfg.LogsToKeep) 42 | if err != nil { 43 | return nil, fmt.Errorf("failed to initialize logger: %w", err) 44 | } 45 | 46 | var ok bool 47 | level, ok := slog.LevelFromString(cfg.LogLevel) 48 | if !ok { 49 | return nil, fmt.Errorf("unknown log level: %q", cfg.LogLevel) 50 | } 51 | 52 | return func(subsystem string) slog.Logger { 53 | log := backend.Logger(subsystem) 54 | log.SetLevel(level) 55 | return log 56 | }, nil 57 | } 58 | 59 | // run is the real main function for vspd. It is necessary to work around the 60 | // fact that deferred functions do not run when os.Exit() is called. 61 | func run() int { 62 | // Load config file and parse CLI args. 63 | cfg, err := vspd.LoadConfig() 64 | if err != nil { 65 | fmt.Fprintf(os.Stderr, "loadConfig error: %v\n", err) 66 | return 1 67 | } 68 | 69 | makeLogger, err := initLogging(cfg) 70 | if err != nil { 71 | fmt.Fprintf(os.Stderr, "initLogging error: %v\n", err) 72 | return 1 73 | } 74 | 75 | log := makeLogger("VSP") 76 | 77 | // Create a context that is canceled when a shutdown request is received 78 | // through an interrupt signal such as SIGINT (Ctrl+C). 79 | ctx := signal.ShutdownListener(log) 80 | 81 | defer log.Criticalf("Shutdown complete") 82 | log.Criticalf("Version %s (Go version %s %s/%s)", version.String(), 83 | runtime.Version(), runtime.GOOS, runtime.GOARCH) 84 | 85 | network := cfg.Network() 86 | 87 | if network == &config.MainNet && version.IsPreRelease() { 88 | log.Warnf("") 89 | log.Warnf("\tWARNING: This is a pre-release version of vspd which should not be used on mainnet") 90 | log.Warnf("") 91 | } 92 | 93 | if cfg.VspClosed { 94 | log.Warnf("") 95 | log.Warnf("\tWARNING: Config --vspclosed is set. This will prevent vspd from accepting new tickets") 96 | log.Warnf("") 97 | } 98 | 99 | if cfg.ConfigFile != "" { 100 | log.Warnf("") 101 | log.Warnf("\tWARNING: Config --configfile is set. This is a deprecated option which has no effect and will be removed in a future release") 102 | log.Warnf("") 103 | } 104 | 105 | if cfg.FeeXPub != "" { 106 | log.Warnf("") 107 | log.Warnf("\tWARNING: Config --feexpub is set. This behavior has been moved into vspadmin and will be removed from vspd in a future release") 108 | log.Warnf("") 109 | } 110 | 111 | // Open database. 112 | db, err := database.Open(cfg.DatabaseFile(), makeLogger(" DB"), maxVoteChangeRecords) 113 | if err != nil { 114 | log.Errorf("Failed to open database: %v", err) 115 | return 1 116 | } 117 | const writeBackup = true 118 | defer db.Close(writeBackup) 119 | 120 | rpcLog := makeLogger("RPC") 121 | 122 | // Create a channel to receive blockConnected notifications from dcrd. 123 | blockNotifChan := make(chan *wire.BlockHeader) 124 | 125 | // Create RPC client for local dcrd instance (used for broadcasting and 126 | // checking the status of fee transactions). 127 | dd := cfg.DcrdDetails() 128 | dcrd := rpc.SetupDcrd(dd.User, dd.Password, dd.Host, dd.Cert, network.Params, rpcLog, blockNotifChan) 129 | 130 | defer dcrd.Close() 131 | 132 | // Create RPC client for remote dcrwallet instances (used for voting). 133 | wd := cfg.WalletDetails() 134 | wallets := rpc.SetupWallet(wd.Users, wd.Passwords, wd.Hosts, wd.Certs, network.Params, rpcLog) 135 | defer wallets.Close() 136 | 137 | // Create webapi server. 138 | apiCfg := webapi.Config{ 139 | Listen: cfg.Listen, 140 | VSPFee: cfg.VSPFee, 141 | Network: network, 142 | SupportEmail: cfg.SupportEmail, 143 | VspClosed: cfg.VspClosed, 144 | VspClosedMsg: cfg.VspClosedMsg, 145 | AdminPass: cfg.AdminPass, 146 | Debug: cfg.WebServerDebug, 147 | Designation: cfg.Designation, 148 | MaxVoteChangeRecords: maxVoteChangeRecords, 149 | VspdVersion: version.String(), 150 | } 151 | api, err := webapi.New(db, makeLogger("API"), dcrd, wallets, apiCfg) 152 | if err != nil { 153 | log.Errorf("Failed to initialize webapi: %v", err) 154 | return 1 155 | } 156 | 157 | // WaitGroup for services to signal when they have shutdown cleanly. 158 | var wg sync.WaitGroup 159 | 160 | // Start the webapi server. 161 | wg.Add(1) 162 | go func() { 163 | api.Run(ctx) 164 | wg.Done() 165 | }() 166 | 167 | // Start vspd. 168 | vspd := vspd.New(network, log, db, dcrd, wallets, blockNotifChan) 169 | wg.Add(1) 170 | go func() { 171 | vspd.Run(ctx) 172 | wg.Done() 173 | }() 174 | 175 | // Periodically write a database backup file. 176 | wg.Add(1) 177 | go func() { 178 | for { 179 | select { 180 | case <-ctx.Done(): 181 | wg.Done() 182 | return 183 | case <-time.After(cfg.BackupInterval): 184 | err := db.WriteHotBackupFile() 185 | if err != nil { 186 | log.Errorf("Failed to write database backup: %v", err) 187 | } 188 | } 189 | } 190 | }() 191 | 192 | // Wait for shutdown tasks to complete before running deferred tasks and 193 | // returning. 194 | wg.Wait() 195 | 196 | return 0 197 | } 198 | --------------------------------------------------------------------------------