├── .changeset └── add_batch_endpoints.md ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml ├── dependabot.yml └── workflows │ ├── main.yml │ ├── prepare-release.yml │ ├── project-add.yml │ ├── publish.yml │ └── ui.yml ├── .gitignore ├── .golangci.yml ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── api ├── api.go ├── api_test.go ├── client.go ├── construct_test.go ├── construct_v2_test.go ├── mine.go └── server.go ├── build ├── build.go ├── gen.go └── meta.go ├── cmd └── walletd │ ├── config.go │ ├── main.go │ ├── miner.go │ └── node.go ├── config └── config.go ├── go.mod ├── go.sum ├── internal ├── testutil │ └── testutil.go └── threadgroup │ ├── threadgroup.go │ └── threadgroup_test.go ├── knope.toml ├── persist └── sqlite │ ├── address_test.go │ ├── addresses.go │ ├── consensus.go │ ├── consensus_test.go │ ├── encoding.go │ ├── events.go │ ├── events_test.go │ ├── init.go │ ├── init.sql │ ├── migrations.go │ ├── migrations_test.go │ ├── options.go │ ├── peers.go │ ├── peers_test.go │ ├── sql.go │ ├── store.go │ ├── utxo.go │ ├── wallet.go │ └── wallet_test.go └── wallet ├── addresses.go ├── addresses_test.go ├── manager.go ├── manager_test.go ├── options.go ├── seed.go ├── update.go ├── wallet.go └── wallet_test.go /.changeset/add_batch_endpoints.md: -------------------------------------------------------------------------------- 1 | --- 2 | default: minor 3 | --- 4 | 5 | # Add batch endpoints 6 | 7 | - `[POST] /batch/addresses/balance` 8 | - `[POST] /batch/addresses/events` 9 | - `[POST] /batch/addresses/unconfirmed` 10 | - `[POST] /batch/addresses/outputs/siacoin` 11 | - `[POST] /batch/addresses/outputs/siafund` -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | labels: ["bug"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thanks for taking the time to fill out this bug report! 9 | Note: Please search to see if an issue already exists for the bug you encountered. 10 | - type: textarea 11 | id: current-behavior 12 | attributes: 13 | label: Current Behavior 14 | description: A concise description of what you're experiencing. 15 | placeholder: Tell us what you see! 16 | validations: 17 | required: true 18 | - type: textarea 19 | id: expected-behavior 20 | attributes: 21 | label: Expected Behavior 22 | description: A concise description of what you expected to happen. 23 | placeholder: Tell us what you want to see! 24 | validations: 25 | required: true 26 | - type: textarea 27 | id: steps-to-reproduce 28 | attributes: 29 | label: Steps to Reproduce 30 | description: Detailed steps to reproduce the behavior. 31 | placeholder: | 32 | 1. Go to '...' 33 | 2. Click on '....' 34 | 3. Scroll down to '....' 35 | 4. See error 36 | - type: input 37 | id: version 38 | attributes: 39 | label: Version 40 | description: What version of walletd are you running? If you are running from source, please provide the commit hash. 41 | placeholder: v0.8.0 42 | validations: 43 | required: true 44 | - type: input 45 | id: os 46 | attributes: 47 | label: What operating system did the problem occur on (e.g. Ubuntu 22.04, macOS 12.0, Windows 11)? 48 | validations: 49 | required: true 50 | - type: textarea 51 | attributes: 52 | label: Anything else? 53 | description: | 54 | Links? References? Anything that will give us more context about the issue you are encountering! 55 | Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. 56 | validations: 57 | required: false -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Sia Community Discord 4 | url: https://discord.gg/sia 5 | about: Join the Sia community discord for more help with Sia or walletd. 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Request a new feature be added to walletd. 3 | labels: ["feature"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thanks for taking the time to fill out this feature request! 9 | Note: Please search to see if an issue already exists for the feature 10 | you want added. 11 | - type: textarea 12 | id: feature-description 13 | attributes: 14 | label: Description 15 | description: | 16 | A description of the feature you want added 17 | Tip: You can attach images by clicking this area and then dragging files in. 18 | placeholder: Tell us what you want! Be as descriptive as possible. 19 | validations: 20 | required: true 21 | - type: input 22 | id: version 23 | attributes: 24 | label: Version 25 | description: What version of walletd are you running? 26 | placeholder: v0.8.0 27 | validations: 28 | required: false 29 | - type: input 30 | id: os 31 | attributes: 32 | label: What operating system are you running (e.g. Ubuntu 22.04, macOS, Windows 11)? 33 | validations: 34 | required: false 35 | - type: textarea 36 | attributes: 37 | label: Anything else? 38 | description: | 39 | Links? References? Anything that will give us more context about the feature! 40 | Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. 41 | validations: 42 | required: false -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | groups: 13 | all-dependencies: 14 | patterns: 15 | - "*" -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Lint & Test 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - master 7 | 8 | env: 9 | CGO_ENABLED: 1 10 | 11 | jobs: 12 | test: 13 | uses: SiaFoundation/workflows/.github/workflows/go-test.yml@master 14 | -------------------------------------------------------------------------------- /.github/workflows/prepare-release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [master] 4 | 5 | permissions: 6 | contents: write 7 | pull-requests: write 8 | 9 | name: Create Release PR 10 | jobs: 11 | prepare-release: 12 | if: "!contains(github.event.head_commit.message, 'chore: prepare release')" # Skip merges from releases 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4.2.2 16 | with: 17 | fetch-depth: 0 18 | - name: Configure Git 19 | run: | 20 | git config --global user.name github-actions[bot] 21 | git config --global user.email 41898282+github-actions[bot]@users.noreply.github.com 22 | - uses: knope-dev/action@407e9ef7c272d2dd53a4e71e39a7839e29933c48 23 | - run: knope prepare-release --verbose 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | continue-on-error: true 27 | -------------------------------------------------------------------------------- /.github/workflows/project-add.yml: -------------------------------------------------------------------------------- 1 | name: Add issues and PRs to Sia project 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | pull_request: 8 | types: 9 | - opened 10 | 11 | jobs: 12 | add-to-project: 13 | uses: SiaFoundation/workflows/.github/workflows/project-add.yml@master 14 | secrets: inherit 15 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | # Controls when the action will run. 4 | on: 5 | # Triggers the workflow on new SemVer tags 6 | push: 7 | branches: 8 | - master 9 | tags: 10 | - "v[0-9]+.[0-9]+.[0-9]+" 11 | - "v[0-9]+.[0-9]+.[0-9]+-**" 12 | 13 | concurrency: 14 | group: ${{ github.workflow }} 15 | cancel-in-progress: false 16 | 17 | jobs: 18 | publish: 19 | uses: SiaFoundation/workflows/.github/workflows/go-publish.yml@master 20 | secrets: inherit 21 | with: 22 | linux-build-args: -tags='timetzdata netgo' -trimpath -a -ldflags '-s -w -linkmode external -extldflags "-static"' 23 | windows-build-args: -tags='timetzdata netgo' -trimpath -a -ldflags '-s -w -linkmode external -extldflags "-static"' 24 | macos-build-args: -tags='timetzdata netgo' -trimpath -a -ldflags '-s -w' 25 | cgo-enabled: 1 26 | project: walletd 27 | project-desc: "walletd: The new Sia wallet" 28 | version-tag: ${{ github.ref_name }} 29 | upload: 30 | if: github.event_name == 'push' && startsWith(github.ref_name, 'v') 31 | runs-on: ubuntu-latest 32 | needs: 33 | - publish 34 | steps: 35 | - uses: actions/checkout@v4.2.2 36 | with: 37 | fetch-depth: 0 38 | - name: Download artifacts 39 | uses: actions/download-artifact@v4.3.0 40 | with: 41 | path: artifacts 42 | - name: Upload artifacts to release 43 | run: | 44 | cd artifacts 45 | gh release upload ${{ github.ref_name }} * 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.RELEASE_PAT }} 48 | continue-on-error: true -------------------------------------------------------------------------------- /.github/workflows/ui.yml: -------------------------------------------------------------------------------- 1 | name: Update UI and open PR 2 | 3 | on: 4 | repository_dispatch: 5 | types: [update-ui] 6 | # Enable manual trigger 7 | workflow_dispatch: 8 | 9 | jobs: 10 | update-ui: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Update UI and open PR 14 | uses: SiaFoundation/workflows/.github/actions/ui-update@master 15 | with: 16 | moduleName: 'walletd' 17 | goVersion: '1.21' 18 | token: ${{ secrets.GITHUB_TOKEN }} 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | walletd.yml 3 | .DS_Store 4 | .vscode/ -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | # Based off of the example file at https://github.com/golangci/golangci-lint 2 | 3 | # options for analysis running 4 | run: 5 | # default concurrency is a available CPU number 6 | concurrency: 4 7 | 8 | # timeout for analysis, e.g. 30s, 5m, default is 1m 9 | timeout: 600s 10 | 11 | # exit code when at least one issue was found, default is 1 12 | issues-exit-code: 1 13 | 14 | # include test files or not, default is true 15 | tests: true 16 | 17 | # list of build tags, all linters use it. Default is empty list. 18 | build-tags: [] 19 | 20 | # output configuration options 21 | output: 22 | # print lines of code with issue, default is true 23 | print-issued-lines: true 24 | 25 | # print linter name in the end of issue text, default is true 26 | print-linter-name: true 27 | 28 | # all available settings of specific linters 29 | linters-settings: 30 | ## Enabled linters: 31 | govet: 32 | # report about shadowed variables 33 | disable-all: false 34 | 35 | tagliatelle: 36 | case: 37 | rules: 38 | json: goCamel 39 | yaml: goCamel 40 | 41 | 42 | gocritic: 43 | # Which checks should be enabled; can't be combined with 'disabled-checks'; 44 | # See https://go-critic.github.io/overview#checks-overview 45 | # To check which checks are enabled run `GL_DEBUG=gocritic golangci-lint run` 46 | # By default list of stable checks is used. 47 | enabled-tags: 48 | - diagnostic 49 | - style 50 | disabled-checks: 51 | # diagnostic 52 | - appendAssign 53 | - commentedOutCode 54 | - uncheckedInlineErr 55 | # style 56 | - httpNoBody 57 | - exitAfterDefer 58 | - ifElseChain 59 | - importShadow 60 | - initClause 61 | - nestingReduce 62 | - octalLiteral 63 | - paramTypeCombine 64 | - ptrToRefParam 65 | - stringsCompare 66 | - tooManyResultsChecker 67 | - typeDefFirst 68 | - typeUnparen 69 | - unlabelStmt 70 | - unnamedResult 71 | - whyNoLint 72 | revive: 73 | ignore-generated-header: true 74 | rules: 75 | - name: blank-imports 76 | disabled: false 77 | - name: bool-literal-in-expr 78 | disabled: false 79 | - name: confusing-naming 80 | disabled: false 81 | - name: confusing-results 82 | disabled: false 83 | - name: constant-logical-expr 84 | disabled: false 85 | - name: context-as-argument 86 | disabled: false 87 | - name: exported 88 | disabled: false 89 | - name: errorf 90 | disabled: false 91 | - name: if-return 92 | disabled: false 93 | - name: indent-error-flow 94 | disabled: true 95 | - name: increment-decrement 96 | disabled: false 97 | - name: modifies-value-receiver 98 | disabled: true 99 | - name: optimize-operands-order 100 | disabled: false 101 | - name: range-val-in-closure 102 | disabled: false 103 | - name: struct-tag 104 | disabled: false 105 | - name: superfluous-else 106 | disabled: false 107 | - name: time-equal 108 | disabled: false 109 | - name: unexported-naming 110 | disabled: false 111 | - name: unexported-return 112 | disabled: false 113 | - name: unnecessary-stmt 114 | disabled: false 115 | - name: unreachable-code 116 | disabled: false 117 | - name: package-comments 118 | disabled: true 119 | 120 | linters: 121 | disable-all: true 122 | fast: false 123 | enable: 124 | - tagliatelle 125 | - gocritic 126 | - gofmt 127 | - revive 128 | - govet 129 | - misspell 130 | - typecheck 131 | - whitespace 132 | 133 | issues: 134 | # Maximum issues count per one linter. Set to 0 to disable. Default is 50. 135 | max-issues-per-linter: 0 136 | 137 | # Maximum count of issues with the same text. Set to 0 to disable. Default is 3. 138 | max-same-issues: 0 139 | 140 | # List of regexps of issue texts to exclude, empty list by default. 141 | # But independently from this option we use default exclude patterns, 142 | # it can be disabled by `exclude-use-default: false`. To list all 143 | # excluded by default patterns execute `golangci-lint run --help` 144 | exclude: [] 145 | 146 | # Independently from option `exclude` we use default exclude patterns, 147 | # it can be disabled by this option. To list all 148 | # excluded by default patterns execute `golangci-lint run --help`. 149 | # Default value for this option is true. 150 | exclude-use-default: false -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/library/golang:1.24 AS builder 2 | 3 | WORKDIR /walletd 4 | 5 | # Install dependencies 6 | COPY go.mod go.sum ./ 7 | RUN go mod download 8 | 9 | # Copy source 10 | COPY . . 11 | 12 | # Enable CGO for sqlite3 support 13 | ENV CGO_ENABLED=1 14 | 15 | RUN go generate ./... 16 | RUN go build -o bin/ -tags='netgo timetzdata' -trimpath -a -ldflags '-s -w -linkmode external -extldflags "-static"' ./cmd/walletd 17 | 18 | FROM debian:bookworm-slim 19 | LABEL maintainer="The Sia Foundation " \ 20 | org.opencontainers.image.description.vendor="The Sia Foundation" \ 21 | org.opencontainers.image.description="A walletd container - send and receive Siacoins and Siafunds" \ 22 | org.opencontainers.image.source="https://github.com/SiaFoundation/walletd" \ 23 | org.opencontainers.image.licenses=MIT 24 | 25 | 26 | # copy binary and prepare data dir. 27 | COPY --from=builder /walletd/bin/* /usr/bin/ 28 | VOLUME [ "/data" ] 29 | 30 | # API port 31 | EXPOSE 9980/tcp 32 | # RPC port 33 | EXPOSE 9981/tcp 34 | 35 | ENV WALLETD_DATA_DIR=/data 36 | ENV WALLETD_CONFIG_FILE=/data/walletd.yml 37 | 38 | ENTRYPOINT [ "walletd", "--http", ":9980" ] 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 The Sia Foundation 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [![Sia](https://sia.tech/assets/banners/sia-banner-expanded-walletd.png)](http://sia.tech) 2 | 3 | [![GoDoc](https://godoc.org/go.sia.tech/walletd?status.svg)](https://godoc.org/go.sia.tech/walletd) 4 | 5 | ## Overview 6 | 7 | `walletd` is the flagship Sia wallet, suitable for miners, exchanges, and 8 | everyday hodlers. Its client-server architecture gives you the flexibility 9 | to access your funds from anywhere, on any device, without compromising the 10 | security of your private keys. The server is agnostic, so you can derive 11 | those keys from a 12-word seed phrase, a legacy (`siad`) 28-word phrase, a 12 | Ledger hardware wallet, or another preferred method. Like other Foundation 13 | node software, `walletd` ships with a slick embedded UI, but developers can 14 | easily build headless integrations leveraging its powerful JSON API. Whether 15 | you're using a single address or millions, `walletd` scales to your needs. 16 | 17 | Setup guides are available at https://docs.sia.tech 18 | 19 | ### Index Mode 20 | `walletd` supports three different index modes for different use cases. 21 | 22 | **Personal** 23 | 24 | In "personal" index mode, `walletd` will only index addresses that are registered in the 25 | wallet. This mode is recommended for most users, as it provides a good balance between 26 | comprehensiveness and resource usage for personal wallets. This is the default 27 | mode for `walletd`. 28 | 29 | When adding addresses with existing history on chain, users will need to manually 30 | initiate a rescan to index the new transactions. This can take some to complete, 31 | depending on the number of blocks that need to be scanned. When adding addresses 32 | with no existing history, a rescan is not necessary. 33 | 34 | **Full** 35 | 36 | In "full" index mode, `walletd` will index the entire blockchain including all addresses 37 | and UTXOs. This is the most comprehensive mode, but it also requires the most 38 | resources. This mode is recommended for exchanges or wallet builders that need 39 | to support a large or unknown number of addresses. 40 | 41 | **None** 42 | 43 | In "none" index mode, `walletd` will treat the database as read-only and not 44 | index any new data. This mode is only useful in situations where another process 45 | is managing the database and `walletd` is only being used to read data. 46 | 47 | ## Configuration 48 | 49 | `walletd` can be configured in multiple ways. Some settings, like the API password, 50 | can be configured via environment variable. Others, like the API port, and data 51 | directory, can be set via command line flags. To simplify more complex configurations, 52 | `walletd` can also be configured via a YAML file. 53 | 54 | The priority of configuration settings is as follows: 55 | 1. Command line flags 56 | 2. YAML file 57 | 3. Environment variables 58 | 59 | ### Default Ports 60 | + `9980` UI and API 61 | + `9981` Sia consensus 62 | 63 | ### Environment Variables 64 | + `WALLETD_API_PASSWORD` - The password required to access the API. 65 | + `WALLETD_CONFIG_FILE` - The path to the YAML configuration file. Defaults to `walletd.yml` in the working directory. 66 | + `WALLETD_LOG_FILE` - The path to the log file. 67 | 68 | ### Command Line Flags 69 | ``` 70 | Usage: 71 | walletd [flags] [action] 72 | 73 | Run 'walletd' with no arguments to start the blockchain node and API server. 74 | 75 | Actions: 76 | version print walletd version 77 | seed generate a recovery phrase 78 | mine run CPU miner 79 | Flags: 80 | -addr string 81 | p2p address to listen on (default ":9981") 82 | -bootstrap 83 | attempt to bootstrap the network (default true) 84 | -debug 85 | enable debug mode with additional profiling and mining endpoints 86 | -dir string 87 | directory to store node state in (default "/Users/username/Library/Application Support/walletd") 88 | -http string 89 | address to serve API on (default "localhost:9980") 90 | -http.public 91 | disables auth on endpoints that should be publicly accessible when running walletd as a service 92 | -index.batch int 93 | max number of blocks to index at a time. Increasing this will increase scan speed, but also increase memory and cpu usage. (default 1000) 94 | -index.mode string 95 | address index mode (personal, full, none) (default "full") 96 | -network string 97 | network to connect to; must be one of 'mainnet', 'zen', 'anagami', or the path to a custom network file for a local testnet 98 | -upnp 99 | attempt to forward ports and discover IP with UPnP 100 | ``` 101 | 102 | ### YAML 103 | All configuration settings can be set in a YAML file. The default location of that file is 104 | - `/etc/walletd/walletd.yml` on Linux 105 | - `~/Library/Application Support/walletd/walletd.yml` on macOS 106 | - `%APPDATA%\SiaFoundation\walletd.yml` on Windows 107 | - `/data/walletd.yml` in the Docker container 108 | 109 | It can be generated using the `walletd config` command. Alternatively a local 110 | configuration can be created manually by creating a file name `walletd.yml` in 111 | the working directory. All fields are optional. 112 | ```yaml 113 | directory: /etc/walletd 114 | autoOpenWebUI: true 115 | http: 116 | address: :9980 117 | password: sia is cool 118 | publicEndpoints: false # when true, auth will be disabled on endpoints that should be publicly accessible when running walletd as a service 119 | consensus: 120 | network: mainnet 121 | syncer: 122 | bootstrap: false 123 | enableUPnP: false 124 | peers: [] 125 | address: :9981 126 | index: 127 | mode: personal # personal, full, none ("full" will index the entire blockchain, "personal" will only index addresses that are registered in the wallet, "none" will treat the database as read-only and not index any new data) 128 | batchSize: 64 # max number of blocks to index at a time (increasing this will increase scan speed, but also increase memory and cpu usage) 129 | log: 130 | level: info # global log level 131 | stdout: 132 | enabled: true # enable logging to stdout 133 | level: debug # override the global log level for stdout 134 | enableANSI: false 135 | format: human # human or JSON 136 | file: 137 | enabled: true # enable logging to a file 138 | level: debug # override the global log level for the file 139 | path: /var/log/walletd.log 140 | format: json # human or JSON 141 | ``` 142 | 143 | ## Building 144 | `walletd` uses SQLite for its persistence. A gcc toolchain is required to build `walletd` 145 | 146 | ```sh 147 | go generate ./... 148 | CGO_ENABLED=1 go build -o bin/ -tags='netgo timetzdata' -trimpath -a -ldflags '-s -w' ./cmd/walletd 149 | ``` 150 | 151 | ## Docker Image 152 | `walletd` includes a Dockerfile for building a Docker image. For building and 153 | running `walletd` within a Docker container. The image can also be pulled from `ghcr.io/siafoundation/walletd`. 154 | 155 | ```sh 156 | docker run -d \ 157 | --name walletd \ 158 | -p 127.0.0.1:9980:9980 \ 159 | -p 9981:9981 \ 160 | -v /data:/data \ 161 | ghcr.io/siafoundation/walletd:latest 162 | ``` 163 | 164 | ### Docker Compose 165 | ```yml 166 | services: 167 | walletd: 168 | image: ghcr.io/siafoundation/walletd:latest 169 | ports: 170 | - 127.0.0.1:9980:9980/tcp 171 | - 9981:9981/tcp 172 | volumes: 173 | - /data:/data 174 | restart: unless-stopped 175 | ``` 176 | 177 | ### Building 178 | 179 | ```sh 180 | docker buildx build --platform linux/amd64,linux/arm64 -t ghcr.io/siafoundation/walletd:master . 181 | ``` 182 | 183 | ### Creating a local testnet 184 | 185 | You can create a custom local testnet by creating a network.json file locally and passing the path to the `--network` CLI flag (i.e. `walletd --network="/var/lib/testnet.json"`). An example file is shown below. You can adjust the parameters of your testnet to increase mining speed and test hardfork activations. 186 | 187 | ```json 188 | { 189 | "network": { 190 | "name": "zen", 191 | "initialCoinbase": "300000000000000000000000000000", 192 | "minimumCoinbase": "30000000000000000000000000000", 193 | "initialTarget": "0000000100000000000000000000000000000000000000000000000000000000", 194 | "blockInterval": 600000000000, 195 | "maturityDelay": 144, 196 | "hardforkDevAddr": { 197 | "height": 1, 198 | "oldAddress": "000000000000000000000000000000000000000000000000000000000000000089eb0d6a8a69", 199 | "newAddress": "000000000000000000000000000000000000000000000000000000000000000089eb0d6a8a69" 200 | }, 201 | "hardforkTax": { 202 | "height": 2 203 | }, 204 | "hardforkStorageProof": { 205 | "height": 5 206 | }, 207 | "hardforkOak": { 208 | "height": 10, 209 | "fixHeight": 12, 210 | "genesisTimestamp": "2023-01-13T00:53:20-08:00" 211 | }, 212 | "hardforkASIC": { 213 | "height": 20, 214 | "oakTime": 10000000000000, 215 | "oakTarget": "0000000100000000000000000000000000000000000000000000000000000000" 216 | }, 217 | "hardforkFoundation": { 218 | "height": 30, 219 | "primaryAddress": "053b2def3cbdd078c19d62ce2b4f0b1a3c5e0ffbeeff01280efb1f8969b2f5bb4fdc680f0807", 220 | "failsafeAddress": "000000000000000000000000000000000000000000000000000000000000000089eb0d6a8a69" 221 | }, 222 | "hardforkV2": { 223 | "allowHeight": 112000, 224 | "requireHeight": 114000 225 | } 226 | }, 227 | "genesis": { 228 | "parentID": "0000000000000000000000000000000000000000000000000000000000000000", 229 | "nonce": 0, 230 | "timestamp": "2023-01-13T00:53:20-08:00", 231 | "minerPayouts": null, 232 | "transactions": [ 233 | { 234 | "id": "268ef8627241b3eb505cea69b21379c4b91c21dfc4b3f3f58c66316249058cfd", 235 | "siacoinOutputs": [ 236 | { 237 | "value": "1000000000000000000000000000000000000", 238 | "address": "3d7f707d05f2e0ec7ccc9220ed7c8af3bc560fbee84d068c2cc28151d617899e1ee8bc069946" 239 | } 240 | ], 241 | "siafundOutputs": [ 242 | { 243 | "value": 10000, 244 | "address": "053b2def3cbdd078c19d62ce2b4f0b1a3c5e0ffbeeff01280efb1f8969b2f5bb4fdc680f0807" 245 | } 246 | ] 247 | } 248 | ] 249 | } 250 | } 251 | ``` 252 | -------------------------------------------------------------------------------- /api/api.go: -------------------------------------------------------------------------------- 1 | // Package api provides a RESTful API client and server for the walletd 2 | // daemon. 3 | 4 | package api 5 | 6 | import ( 7 | "encoding/json" 8 | "time" 9 | 10 | "go.sia.tech/core/consensus" 11 | "go.sia.tech/core/types" 12 | "go.sia.tech/walletd/v2/wallet" 13 | ) 14 | 15 | // A StateResponse returns information about the current state of the walletd 16 | // daemon. 17 | type StateResponse struct { 18 | Version string `json:"version"` 19 | Commit string `json:"commit"` 20 | OS string `json:"os"` 21 | BuildTime time.Time `json:"buildTime"` 22 | StartTime time.Time `json:"startTime"` 23 | IndexMode wallet.IndexMode `json:"indexMode"` 24 | } 25 | 26 | // A GatewayPeer is a currently-connected peer. 27 | type GatewayPeer struct { 28 | Address string `json:"address"` 29 | Inbound bool `json:"inbound"` 30 | Version string `json:"version"` 31 | 32 | FirstSeen time.Time `json:"firstSeen,omitempty"` 33 | ConnectedSince time.Time `json:"connectedSince,omitempty"` 34 | SyncedBlocks uint64 `json:"syncedBlocks,omitempty"` 35 | SyncDuration time.Duration `json:"syncDuration,omitempty"` 36 | } 37 | 38 | // TxpoolBroadcastRequest is the request type for /txpool/broadcast. 39 | type TxpoolBroadcastRequest struct { 40 | Basis types.ChainIndex `json:"basis"` 41 | Transactions []types.Transaction `json:"transactions"` 42 | V2Transactions []types.V2Transaction `json:"v2transactions"` 43 | } 44 | 45 | // TxpoolBroadcastResponse is the response type for /txpool/broadcast. 46 | type TxpoolBroadcastResponse struct { 47 | Basis types.ChainIndex `json:"basis"` 48 | Transactions []types.Transaction `json:"transactions"` 49 | V2Transactions []types.V2Transaction `json:"v2transactions"` 50 | } 51 | 52 | // TxpoolTransactionsResponse is the response type for /txpool/transactions. 53 | type TxpoolTransactionsResponse struct { 54 | Basis types.ChainIndex `json:"basis"` 55 | Transactions []types.Transaction `json:"transactions"` 56 | V2Transactions []types.V2Transaction `json:"v2transactions"` 57 | } 58 | 59 | // TxpoolUpdateV2TransactionsRequest is the request type for /txpool/transactions/v2/basis. 60 | type TxpoolUpdateV2TransactionsRequest struct { 61 | Basis types.ChainIndex `json:"basis"` 62 | Target types.ChainIndex `json:"target"` 63 | Transactions []types.V2Transaction `json:"transactions"` 64 | } 65 | 66 | // ConsensusCheckpointResponse is the response type for GET /consensus/checkpoint/:id. 67 | type ConsensusCheckpointResponse struct { 68 | State consensus.State `json:"state"` 69 | Block types.Block `json:"block"` 70 | } 71 | 72 | // TxpoolUpdateV2TransactionsResponse is the response type for /txpool/transactions/v2/basis. 73 | type TxpoolUpdateV2TransactionsResponse struct { 74 | Basis types.ChainIndex `json:"basis"` 75 | Transactions []types.V2Transaction `json:"transactions"` 76 | } 77 | 78 | // BalanceResponse is the response type for /wallets/:id/balance. 79 | type BalanceResponse wallet.Balance 80 | 81 | // WalletReserveRequest is the request type for /wallets/:id/reserve. 82 | type WalletReserveRequest struct { 83 | SiacoinOutputs []types.SiacoinOutputID `json:"siacoinOutputs"` 84 | SiafundOutputs []types.SiafundOutputID `json:"siafundOutputs"` 85 | } 86 | 87 | // A WalletUpdateRequest is a request to update a wallet 88 | type WalletUpdateRequest struct { 89 | Name string `json:"name"` 90 | Description string `json:"description"` 91 | Metadata json.RawMessage `json:"metadata"` 92 | } 93 | 94 | // WalletReleaseRequest is the request type for /wallets/:id/release. 95 | type WalletReleaseRequest struct { 96 | SiacoinOutputs []types.SiacoinOutputID `json:"siacoinOutputs"` 97 | SiafundOutputs []types.SiafundOutputID `json:"siafundOutputs"` 98 | } 99 | 100 | // WalletFundRequest is the request type for /wallets/:id/fund. 101 | type WalletFundRequest struct { 102 | Transaction types.Transaction `json:"transaction"` 103 | Amount types.Currency `json:"amount"` 104 | ChangeAddress types.Address `json:"changeAddress"` 105 | } 106 | 107 | // WalletFundSFRequest is the request type for /wallets/:id/fundsf. 108 | type WalletFundSFRequest struct { 109 | Transaction types.Transaction `json:"transaction"` 110 | Amount uint64 `json:"amount"` 111 | ChangeAddress types.Address `json:"changeAddress"` 112 | ClaimAddress types.Address `json:"claimAddress"` 113 | } 114 | 115 | // WalletFundResponse is the response type for /wallets/:id/fund. 116 | type WalletFundResponse struct { 117 | Basis types.ChainIndex `json:"basis"` 118 | Transaction types.Transaction `json:"transaction"` 119 | ToSign []types.Hash256 `json:"toSign"` 120 | DependsOn []types.Transaction `json:"dependsOn"` 121 | } 122 | 123 | // WalletConstructRequest is the request type for /wallets/:id/construct. 124 | type WalletConstructRequest struct { 125 | Siacoins []types.SiacoinOutput `json:"siacoins"` 126 | Siafunds []types.SiafundOutput `json:"siafunds"` 127 | ChangeAddress types.Address `json:"changeAddress"` 128 | } 129 | 130 | // SignaturePayload is a signature that is required to finalize a transaction. 131 | type SignaturePayload struct { 132 | PublicKey types.PublicKey `json:"publicKey"` 133 | SigHash types.Hash256 `json:"sigHash"` 134 | } 135 | 136 | // WalletConstructResponse is the response type for /wallets/:id/construct/transaction. 137 | type WalletConstructResponse struct { 138 | Basis types.ChainIndex `json:"basis"` 139 | ID types.TransactionID `json:"id"` 140 | Transaction types.Transaction `json:"transaction"` 141 | EstimatedFee types.Currency `json:"estimatedFee"` 142 | } 143 | 144 | // WalletConstructV2Response is the response type for /wallets/:id/construct/v2/transaction. 145 | type WalletConstructV2Response struct { 146 | Basis types.ChainIndex `json:"basis"` 147 | ID types.TransactionID `json:"id"` 148 | Transaction types.V2Transaction `json:"transaction"` 149 | EstimatedFee types.Currency `json:"estimatedFee"` 150 | } 151 | 152 | // SeedSignRequest requests that a transaction be signed using the keys derived 153 | // from the given indices. 154 | type SeedSignRequest struct { 155 | Transaction types.Transaction `json:"transaction"` 156 | Keys []uint64 `json:"keys"` 157 | } 158 | 159 | // RescanResponse contains information about the state of a chain rescan. 160 | type RescanResponse struct { 161 | StartIndex types.ChainIndex `json:"startIndex"` 162 | Index types.ChainIndex `json:"index"` 163 | StartTime time.Time `json:"startTime"` 164 | Error *string `json:"error,omitempty"` 165 | } 166 | 167 | // An ApplyUpdate is a consensus update that was applied to the best chain. 168 | type ApplyUpdate struct { 169 | Update consensus.ApplyUpdate `json:"update"` 170 | State consensus.State `json:"state"` 171 | Block types.Block `json:"block"` 172 | } 173 | 174 | // A RevertUpdate is a consensus update that was reverted from the best chain. 175 | type RevertUpdate struct { 176 | Update consensus.RevertUpdate `json:"update"` 177 | State consensus.State `json:"state"` 178 | Block types.Block `json:"block"` 179 | } 180 | 181 | // ConsensusUpdatesResponse is the response type for /consensus/updates/:index. 182 | type ConsensusUpdatesResponse struct { 183 | Applied []ApplyUpdate `json:"applied"` 184 | Reverted []RevertUpdate `json:"reverted"` 185 | } 186 | 187 | // DebugMineRequest is the request type for /debug/mine. 188 | type DebugMineRequest struct { 189 | Blocks int `json:"blocks"` 190 | Address types.Address `json:"address"` 191 | } 192 | 193 | // SiacoinElementsResponse is the response type for any endpoint that returns 194 | // siacoin UTXOs 195 | type SiacoinElementsResponse struct { 196 | Basis types.ChainIndex `json:"basis"` 197 | Outputs []types.SiacoinElement `json:"outputs"` 198 | } 199 | 200 | // SiafundElementsResponse is the response type for any endpoint that returns 201 | // siafund UTXOs 202 | type SiafundElementsResponse struct { 203 | Basis types.ChainIndex `json:"basis"` 204 | Outputs []types.SiafundElement `json:"outputs"` 205 | } 206 | 207 | // UnspentSiacoinElementsResponse is the response type for any endpoint that returns 208 | // siacoin UTXOs 209 | type UnspentSiacoinElementsResponse struct { 210 | Basis types.ChainIndex `json:"basis"` 211 | Outputs []wallet.UnspentSiacoinElement `json:"outputs"` 212 | } 213 | 214 | // UnspentSiafundElementsResponse is the response type for any endpoint that returns 215 | // siafund UTXOs 216 | type UnspentSiafundElementsResponse struct { 217 | Basis types.ChainIndex `json:"basis"` 218 | Outputs []wallet.UnspentSiafundElement `json:"outputs"` 219 | } 220 | 221 | // AddressSiacoinElementsResponse is the response type for any endpoint that returns 222 | // siacoin UTXOs 223 | type AddressSiacoinElementsResponse struct { 224 | Basis types.ChainIndex `json:"basis"` 225 | Outputs []wallet.UnspentSiacoinElement `json:"outputs"` 226 | } 227 | 228 | // AddressSiafundElementsResponse is the response type for any endpoint that returns 229 | // siafund UTXOs 230 | type AddressSiafundElementsResponse struct { 231 | Basis types.ChainIndex `json:"basis"` 232 | Outputs []wallet.UnspentSiafundElement `json:"outputs"` 233 | } 234 | 235 | // CheckAddressesRequest is the request type for [POST] /check/addresses. 236 | type CheckAddressesRequest struct { 237 | Addresses []types.Address `json:"addresses"` 238 | } 239 | 240 | // CheckAddressesResponse is the response type for [POST] /check/addresses. 241 | type CheckAddressesResponse struct { 242 | Known bool `json:"known"` 243 | } 244 | 245 | // ElementSpentResponse is the response type for /outputs/siacoin/:id/spent and 246 | // /outputs/siafund/:id/spent. 247 | type ElementSpentResponse struct { 248 | Spent bool `json:"spent"` 249 | Event *wallet.Event `json:"event,omitempty"` 250 | } 251 | 252 | // BatchAddressesRequest is the request type for batch 253 | // address operations. 254 | type BatchAddressesRequest struct { 255 | Addresses []types.Address `json:"addresses"` 256 | } 257 | -------------------------------------------------------------------------------- /api/construct_test.go: -------------------------------------------------------------------------------- 1 | package api_test 2 | 3 | import ( 4 | "go.sia.tech/core/types" 5 | "go.sia.tech/walletd/v2/api" 6 | "go.sia.tech/walletd/v2/wallet" 7 | ) 8 | 9 | func ExampleWalletClient_Construct() { 10 | const ( 11 | apiAddress = "localhost:9980/api" 12 | apiPassword = "password" 13 | ) 14 | 15 | client := api.NewClient(apiAddress, apiPassword) 16 | 17 | // generate a recovery phrase 18 | phrase := wallet.NewSeedPhrase() 19 | 20 | // derive an address from the recovery phrase 21 | var seed [32]byte 22 | defer clear(seed[:]) 23 | if err := wallet.SeedFromPhrase(&seed, phrase); err != nil { 24 | panic(err) 25 | } 26 | 27 | privateKey := wallet.KeyFromSeed(&seed, 0) 28 | spendPolicy := types.SpendPolicy{ 29 | Type: types.PolicyTypeUnlockConditions{ 30 | PublicKeys: []types.UnlockKey{ 31 | privateKey.PublicKey().UnlockKey(), 32 | }, 33 | SignaturesRequired: 1, 34 | }, 35 | } 36 | address := spendPolicy.Address() 37 | 38 | // add a wallet 39 | w1, err := client.AddWallet(api.WalletUpdateRequest{ 40 | Name: "test", 41 | Description: "test wallet", 42 | }) 43 | if err != nil { 44 | panic(err) 45 | } 46 | 47 | // init the wallet client to interact with the wallet 48 | wc := client.Wallet(w1.ID) 49 | 50 | err = wc.AddAddress(wallet.Address{ 51 | Address: address, 52 | SpendPolicy: &spendPolicy, 53 | }) 54 | if err != nil { 55 | panic(err) 56 | } 57 | 58 | // create a transaction 59 | resp, err := wc.Construct([]types.SiacoinOutput{ 60 | {Address: types.VoidAddress, Value: types.Siacoins(1)}, 61 | }, nil, address) 62 | if err != nil { 63 | panic(err) 64 | } 65 | txn := resp.Transaction 66 | 67 | // sign the transaction 68 | cs, err := client.ConsensusTipState() 69 | if err != nil { 70 | panic(err) 71 | } 72 | 73 | for i, sig := range txn.Signatures { 74 | sigHash := cs.WholeSigHash(txn, sig.ParentID, 0, 0, nil) 75 | sig := privateKey.SignHash(sigHash) 76 | txn.Signatures[i].Signature = sig[:] 77 | } 78 | 79 | // broadcast the transaction 80 | if _, err := client.TxpoolBroadcast(resp.Basis, []types.Transaction{txn}, nil); err != nil { 81 | panic(err) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /api/construct_v2_test.go: -------------------------------------------------------------------------------- 1 | package api_test 2 | 3 | import ( 4 | "go.sia.tech/core/types" 5 | "go.sia.tech/walletd/v2/api" 6 | "go.sia.tech/walletd/v2/wallet" 7 | ) 8 | 9 | func ExampleWalletClient_ConstructV2() { 10 | const ( 11 | apiAddress = "localhost:9980/api" 12 | apiPassword = "password" 13 | ) 14 | 15 | client := api.NewClient(apiAddress, apiPassword) 16 | 17 | // generate a recovery phrase 18 | phrase := wallet.NewSeedPhrase() 19 | 20 | // derive an address from the recovery phrase 21 | var seed [32]byte 22 | defer clear(seed[:]) 23 | if err := wallet.SeedFromPhrase(&seed, phrase); err != nil { 24 | panic(err) 25 | } 26 | 27 | privateKey := wallet.KeyFromSeed(&seed, 0) 28 | spendPolicy := types.SpendPolicy{ 29 | Type: types.PolicyTypeUnlockConditions{ 30 | PublicKeys: []types.UnlockKey{ 31 | privateKey.PublicKey().UnlockKey(), 32 | }, 33 | SignaturesRequired: 1, 34 | }, 35 | } 36 | address := spendPolicy.Address() 37 | 38 | // add a wallet 39 | w1, err := client.AddWallet(api.WalletUpdateRequest{ 40 | Name: "test", 41 | Description: "test wallet", 42 | }) 43 | if err != nil { 44 | panic(err) 45 | } 46 | 47 | // init the wallet client to interact with the wallet 48 | wc := client.Wallet(w1.ID) 49 | 50 | err = wc.AddAddress(wallet.Address{ 51 | Address: address, 52 | SpendPolicy: &spendPolicy, 53 | }) 54 | if err != nil { 55 | panic(err) 56 | } 57 | 58 | // create a transaction 59 | resp, err := wc.ConstructV2([]types.SiacoinOutput{ 60 | {Address: types.VoidAddress, Value: types.Siacoins(1)}, 61 | }, nil, address) 62 | if err != nil { 63 | panic(err) 64 | } 65 | txn := resp.Transaction 66 | 67 | // sign the transaction 68 | cs, err := client.ConsensusTipState() 69 | if err != nil { 70 | panic(err) 71 | } 72 | 73 | sigHash := cs.InputSigHash(txn) 74 | sig := privateKey.SignHash(sigHash) 75 | for i := range txn.SiacoinInputs { 76 | txn.SiacoinInputs[i].SatisfiedPolicy.Signatures = []types.Signature{sig} 77 | } 78 | 79 | // broadcast the transaction 80 | if _, err := client.TxpoolBroadcast(resp.Basis, nil, []types.V2Transaction{txn}); err != nil { 81 | panic(err) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /api/mine.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "go.sia.tech/core/types" 8 | ) 9 | 10 | // mineBlock constructs a block from the provided address and the transactions 11 | // in the txpool, and attempts to find a nonce for it that meets the PoW target. 12 | func mineBlock(ctx context.Context, cm ChainManager, addr types.Address) (types.Block, error) { 13 | cs := cm.TipState() 14 | txns := cm.PoolTransactions() 15 | v2Txns := cm.V2PoolTransactions() 16 | 17 | b := types.Block{ 18 | ParentID: cs.Index.ID, 19 | Timestamp: types.CurrentTimestamp(), 20 | MinerPayouts: []types.SiacoinOutput{{ 21 | Value: cs.BlockReward(), 22 | Address: addr, 23 | }}, 24 | } 25 | 26 | if cs.Index.Height >= cs.Network.HardforkV2.AllowHeight { 27 | b.V2 = &types.V2BlockData{ 28 | Height: cs.Index.Height + 1, 29 | } 30 | } 31 | 32 | var weight uint64 33 | for _, txn := range txns { 34 | if weight += cs.TransactionWeight(txn); weight > cs.MaxBlockWeight() { 35 | break 36 | } 37 | b.Transactions = append(b.Transactions, txn) 38 | b.MinerPayouts[0].Value = b.MinerPayouts[0].Value.Add(txn.TotalFees()) 39 | } 40 | for _, txn := range v2Txns { 41 | if weight += cs.V2TransactionWeight(txn); weight > cs.MaxBlockWeight() { 42 | break 43 | } 44 | b.V2.Transactions = append(b.V2.Transactions, txn) 45 | b.MinerPayouts[0].Value = b.MinerPayouts[0].Value.Add(txn.MinerFee) 46 | } 47 | if b.V2 != nil { 48 | b.V2.Commitment = cs.Commitment(addr, b.Transactions, b.V2.Transactions) 49 | } 50 | 51 | b.Nonce = 0 52 | factor := cs.NonceFactor() 53 | for b.ID().CmpWork(cs.ChildTarget) < 0 { 54 | select { 55 | case <-ctx.Done(): 56 | return types.Block{}, ctx.Err() 57 | default: 58 | } 59 | 60 | // tip changed, abort mining 61 | if cm.Tip() != cs.Index { 62 | return types.Block{}, errors.New("tip changed") 63 | } 64 | 65 | b.Nonce += factor 66 | } 67 | return b, nil 68 | } 69 | -------------------------------------------------------------------------------- /build/build.go: -------------------------------------------------------------------------------- 1 | // Package build contains build-time information. 2 | package build 3 | 4 | //go:generate go run gen.go 5 | 6 | import "time" 7 | 8 | // Commit returns the commit hash of walletd 9 | func Commit() string { 10 | return commit 11 | } 12 | 13 | // Version returns the version of walletd 14 | func Version() string { 15 | return version 16 | } 17 | 18 | // Time returns the time at which the binary was built. 19 | func Time() time.Time { 20 | return time.Unix(buildTime, 0) 21 | } 22 | -------------------------------------------------------------------------------- /build/gen.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | 3 | // This script generates meta.go which contains version info for the walletd binary. It can be run with `go generate`. 4 | package main 5 | 6 | import ( 7 | "encoding/json" 8 | "errors" 9 | "fmt" 10 | "log" 11 | "os" 12 | "os/exec" 13 | "strings" 14 | "text/template" 15 | "time" 16 | ) 17 | 18 | const logFormat = `{%n "commit": "%H",%n "shortCommit": "%h",%n "timestamp": "%cD",%n "tag": "%(describe:tags=true)"%n}` 19 | 20 | type ( 21 | gitTime time.Time 22 | 23 | gitMeta struct { 24 | Commit string `json:"commit"` 25 | ShortCommit string `json:"shortCommit"` 26 | Timestamp gitTime `json:"timestamp"` 27 | Tag string `json:"tag"` 28 | } 29 | ) 30 | 31 | var buildTemplate = template.Must(template.New("").Parse(`// Code generated by go generate; DO NOT EDIT. 32 | // This file was generated by go generate at {{ .RunTime }}. 33 | package build 34 | 35 | const ( 36 | commit = "{{ .Commit }}" 37 | version = "{{ .Version }}" 38 | buildTime = {{ .UnixTimestamp }} 39 | ) 40 | `)) 41 | 42 | // UnmarshalJSON implements the json.Unmarshaler interface. 43 | func (t *gitTime) UnmarshalJSON(buf []byte) error { 44 | timeFormats := []string{ 45 | time.RFC1123Z, 46 | "Mon, 2 Jan 2006 15:04:05 -0700", 47 | "2006-01-02 15:04:05 -0700", 48 | time.UnixDate, 49 | time.ANSIC, 50 | time.RFC3339, 51 | time.RFC1123, 52 | } 53 | 54 | for _, format := range timeFormats { 55 | parsed, err := time.Parse(format, strings.Trim(string(buf), `"`)) 56 | if err == nil { 57 | *t = gitTime(parsed) 58 | return nil 59 | } 60 | } 61 | return errors.New("failed to parse time") 62 | } 63 | 64 | func getGitMeta() (meta gitMeta, _ error) { 65 | cmd := exec.Command("git", "log", "-1", "--pretty=format:"+logFormat+"") 66 | buf, err := cmd.Output() 67 | if err != nil { 68 | if err, ok := err.(*exec.ExitError); ok && len(err.Stderr) > 0 { 69 | return gitMeta{}, fmt.Errorf("command failed: %w", errors.New(string(err.Stderr))) 70 | } 71 | return gitMeta{}, fmt.Errorf("failed to execute command: %w", err) 72 | } else if err := json.Unmarshal(buf, &meta); err != nil { 73 | return gitMeta{}, fmt.Errorf("failed to unmarshal json: %w", err) 74 | } 75 | return 76 | } 77 | 78 | func main() { 79 | meta, err := getGitMeta() 80 | if err != nil { 81 | log.Fatalln(err) 82 | } 83 | 84 | commit := meta.ShortCommit 85 | version := meta.Tag 86 | if len(version) == 0 { 87 | // no version, use commit and current time for development 88 | version = commit 89 | meta.Timestamp = gitTime(time.Now()) 90 | } 91 | 92 | f, err := os.Create("meta.go") 93 | if err != nil { 94 | log.Fatalln(err) 95 | } 96 | defer f.Close() 97 | 98 | err = buildTemplate.Execute(f, struct { 99 | Commit string 100 | Version string 101 | UnixTimestamp int64 102 | 103 | RunTime string 104 | }{ 105 | Commit: commit, 106 | Version: version, 107 | UnixTimestamp: time.Time(meta.Timestamp).Unix(), 108 | 109 | RunTime: time.Now().Format(time.RFC3339), 110 | }) 111 | if err != nil { 112 | log.Fatalln(err) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /build/meta.go: -------------------------------------------------------------------------------- 1 | package build 2 | 3 | const ( 4 | commit = "?" 5 | version = "?" 6 | buildTime = 0 7 | ) 8 | -------------------------------------------------------------------------------- /cmd/walletd/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "fmt" 7 | "net" 8 | "os" 9 | "path/filepath" 10 | "runtime" 11 | "strconv" 12 | "strings" 13 | 14 | "go.sia.tech/walletd/v2/wallet" 15 | "golang.org/x/term" 16 | "gopkg.in/yaml.v3" 17 | ) 18 | 19 | // readPasswordInput reads a password from stdin. 20 | func readPasswordInput(context string) string { 21 | fmt.Printf("%s: ", context) 22 | input, err := term.ReadPassword(int(os.Stdin.Fd())) 23 | checkFatalError("failed to read password input", err) 24 | fmt.Println("") 25 | return string(input) 26 | } 27 | 28 | func readInput(context string) string { 29 | fmt.Printf("%s: ", context) 30 | r := bufio.NewReader(os.Stdin) 31 | input, err := r.ReadString('\n') 32 | checkFatalError("failed to read input", err) 33 | return strings.TrimSpace(input) 34 | } 35 | 36 | // wrapANSI wraps the output in ANSI escape codes if enabled. 37 | func wrapANSI(prefix, output, suffix string) string { 38 | if cfg.Log.StdOut.EnableANSI { 39 | return prefix + output + suffix 40 | } 41 | return output 42 | } 43 | 44 | func humanList(s []string, sep string) string { 45 | if len(s) == 0 { 46 | return "" 47 | } else if len(s) == 1 { 48 | return fmt.Sprintf(`%q`, s[0]) 49 | } else if len(s) == 2 { 50 | return fmt.Sprintf(`%q %s %q`, s[0], sep, s[1]) 51 | } 52 | 53 | var sb strings.Builder 54 | for i, v := range s { 55 | if i != 0 { 56 | sb.WriteString(", ") 57 | } 58 | if i == len(s)-1 { 59 | sb.WriteString("or ") 60 | } 61 | sb.WriteString(`"`) 62 | sb.WriteString(v) 63 | sb.WriteString(`"`) 64 | } 65 | return sb.String() 66 | } 67 | 68 | func promptQuestion(question string, answers []string) string { 69 | for { 70 | input := readInput(fmt.Sprintf("%s (%s)", question, strings.Join(answers, "/"))) 71 | for _, answer := range answers { 72 | if strings.EqualFold(input, answer) { 73 | return answer 74 | } 75 | } 76 | fmt.Println(wrapANSI("\033[31m", fmt.Sprintf("Answer must be %s", humanList(answers, "or")), "\033[0m")) 77 | } 78 | } 79 | 80 | func promptYesNo(question string) bool { 81 | answer := promptQuestion(question, []string{"yes", "no"}) 82 | return strings.EqualFold(answer, "yes") 83 | } 84 | 85 | // stdoutError prints an error message to stdout 86 | func stdoutError(msg string) { 87 | if cfg.Log.StdOut.EnableANSI { 88 | fmt.Println(wrapANSI("\033[31m", msg, "\033[0m")) 89 | } else { 90 | fmt.Println(msg) 91 | } 92 | } 93 | 94 | func setAPIPassword() { 95 | // retry until a valid API password is entered 96 | for { 97 | fmt.Println("Please choose a password to unlock walletd.") 98 | fmt.Println("This password will be required to access the admin UI in your web browser.") 99 | fmt.Println("(The password must be at least 4 characters.)") 100 | cfg.HTTP.Password = readPasswordInput("Enter password") 101 | if len(cfg.HTTP.Password) >= 4 { 102 | break 103 | } 104 | 105 | fmt.Println(wrapANSI("\033[31m", "Password must be at least 4 characters!", "\033[0m")) 106 | fmt.Println("") 107 | } 108 | } 109 | 110 | func setDataDirectory() { 111 | if cfg.Directory == "" { 112 | cfg.Directory = "." 113 | } 114 | 115 | dir, err := filepath.Abs(cfg.Directory) 116 | checkFatalError("failed to get absolute path of data directory", err) 117 | 118 | fmt.Println("The data directory is where walletd will store its metadata and consensus data.") 119 | fmt.Println("This directory should be on a fast, reliable storage device, preferably an SSD.") 120 | fmt.Println("") 121 | 122 | _, existsErr := os.Stat(filepath.Join(cfg.Directory, "walletd.sqlite3")) 123 | dataExists := existsErr == nil 124 | if dataExists { 125 | fmt.Println(wrapANSI("\033[33m", "There is existing data in the data directory.", "\033[0m")) 126 | fmt.Println(wrapANSI("\033[33m", "If you change your data directory, you will need to manually move consensus, gateway, tpool, and walletd.sqlite3 to the new directory.", "\033[0m")) 127 | } 128 | 129 | if !promptYesNo("Would you like to change the data directory? (Current: " + dir + ")") { 130 | return 131 | } 132 | cfg.Directory = readInput("Enter data directory") 133 | } 134 | 135 | func setListenAddress(context string, value *string) { 136 | // will continue to prompt until a valid value is entered 137 | for { 138 | input := readInput(fmt.Sprintf("%s (currently %q)", context, *value)) 139 | if input == "" { 140 | return 141 | } 142 | 143 | host, port, err := net.SplitHostPort(input) 144 | if err != nil { 145 | stdoutError(fmt.Sprintf("Invalid %s port %q: %s", context, input, err.Error())) 146 | continue 147 | } 148 | 149 | n, err := strconv.Atoi(port) 150 | if err != nil { 151 | stdoutError(fmt.Sprintf("Invalid %s port %q: %s", context, input, err.Error())) 152 | continue 153 | } else if n < 0 || n > 65535 { 154 | stdoutError(fmt.Sprintf("Invalid %s port %q: must be between 0 and 65535", context, input)) 155 | continue 156 | } 157 | *value = net.JoinHostPort(host, port) 158 | return 159 | } 160 | } 161 | 162 | func setAdvancedConfig() { 163 | if !promptYesNo("Would you like to configure advanced settings?") { 164 | return 165 | } 166 | 167 | fmt.Println("") 168 | fmt.Println("Advanced settings are used to configure walletd's behavior.") 169 | fmt.Println("You can leave these settings blank to use the defaults.") 170 | fmt.Println("") 171 | 172 | fmt.Println("The HTTP address is used to serve the host's admin API.") 173 | fmt.Println("The admin API is used to configure the host.") 174 | fmt.Println("It should not be exposed to the public internet without setting up a reverse proxy.") 175 | setListenAddress("HTTP Address", &cfg.HTTP.Address) 176 | 177 | fmt.Println("") 178 | fmt.Println("The syncer address is used to connect to the Sia network.") 179 | fmt.Println("It should be reachable from other Sia nodes.") 180 | setListenAddress("Syncer Address", &cfg.Syncer.Address) 181 | 182 | fmt.Println("") 183 | fmt.Println("Index mode determines how much of the blockchain to store.") 184 | fmt.Println(`"personal" mode stores events only relevant to addresses associated with a wallet.`) 185 | fmt.Println("To add new addresses, the wallet must be rescanned. This is the default mode.") 186 | fmt.Println("") 187 | fmt.Println(`"full" mode stores all blockchain events. This mode is useful for exchanges and shared wallet clients.`) 188 | fmt.Println("This mode requires significantly more disk space, but does not require rescanning when adding new addresses.") 189 | fmt.Println("") 190 | fmt.Println("This cannot be changed later without resetting walletd.") 191 | fmt.Printf("Currently %q\n", cfg.Index.Mode) 192 | mode := readInput(`Enter index mode ("personal" or "full")`) 193 | switch { 194 | case strings.EqualFold(mode, "personal"): 195 | cfg.Index.Mode = wallet.IndexModePersonal 196 | case strings.EqualFold(mode, "full"): 197 | cfg.Index.Mode = wallet.IndexModeFull 198 | default: 199 | checkFatalError("invalid index mode", errors.New("must be either 'personal' or 'full'")) 200 | } 201 | 202 | fmt.Println("") 203 | fmt.Println("The network is the blockchain network that walletd will connect to.") 204 | fmt.Println("Mainnet is the default network.") 205 | fmt.Println("Zen is a production-like testnet.") 206 | fmt.Println("This cannot be changed later without resetting walletd.") 207 | fmt.Printf("Currently %q\n", cfg.Consensus.Network) 208 | cfg.Consensus.Network = readInput(`Enter network ("mainnet" or "zen")`) 209 | } 210 | 211 | func configPath() string { 212 | if str := os.Getenv(configFileEnvVar); str != "" { 213 | return str 214 | } 215 | 216 | switch runtime.GOOS { 217 | case "windows": 218 | return filepath.Join(os.Getenv("APPDATA"), "walletd", "walletd.yml") 219 | case "darwin": 220 | return filepath.Join(os.Getenv("HOME"), "Library", "Application Support", "walletd", "walletd.yml") 221 | case "linux", "freebsd", "openbsd": 222 | return filepath.Join(string(filepath.Separator), "etc", "walletd", "walletd.yml") 223 | default: 224 | return "walletd.yml" 225 | } 226 | } 227 | 228 | func buildConfig(fp string) { 229 | fmt.Println("walletd Configuration Wizard") 230 | fmt.Println("This wizard will help you configure walletd for the first time.") 231 | fmt.Println("You can always change these settings with the config command or by editing the config file.") 232 | 233 | // write the config file 234 | if fp == "" { 235 | fp = configPath() 236 | } 237 | 238 | fmt.Println("") 239 | fmt.Printf("Config Location %q\n", fp) 240 | 241 | if _, err := os.Stat(fp); err == nil { 242 | if !promptYesNo(fmt.Sprintf("%q already exists. Would you like to overwrite it?", fp)) { 243 | return 244 | } 245 | } else if !errors.Is(err, os.ErrNotExist) { 246 | checkFatalError("failed to check if config file exists", err) 247 | } else { 248 | // ensure the config directory exists 249 | checkFatalError("failed to create config directory", os.MkdirAll(filepath.Dir(fp), 0700)) 250 | } 251 | 252 | fmt.Println("") 253 | setDataDirectory() 254 | 255 | fmt.Println("") 256 | setAPIPassword() 257 | 258 | fmt.Println("") 259 | setAdvancedConfig() 260 | 261 | // write the config file 262 | f, err := os.Create(fp) 263 | checkFatalError("failed to create config file", err) 264 | defer f.Close() 265 | 266 | enc := yaml.NewEncoder(f) 267 | defer enc.Close() 268 | 269 | checkFatalError("failed to encode config file", enc.Encode(cfg)) 270 | checkFatalError("failed to sync config file", f.Sync()) 271 | } 272 | -------------------------------------------------------------------------------- /cmd/walletd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "os/signal" 9 | "path/filepath" 10 | "runtime" 11 | "syscall" 12 | 13 | "go.sia.tech/core/types" 14 | "go.sia.tech/walletd/v2/api" 15 | "go.sia.tech/walletd/v2/build" 16 | "go.sia.tech/walletd/v2/config" 17 | "go.sia.tech/walletd/v2/wallet" 18 | "go.uber.org/zap" 19 | "go.uber.org/zap/zapcore" 20 | "lukechampine.com/flagg" 21 | ) 22 | 23 | const ( 24 | apiPasswordEnvVar = "WALLETD_API_PASSWORD" 25 | configFileEnvVar = "WALLETD_CONFIG_FILE" 26 | dataDirEnvVar = "WALLETD_DATA_DIR" 27 | logFileEnvVar = "WALLETD_LOG_FILE_PATH" 28 | ) 29 | 30 | const ( 31 | rootUsage = `Usage: 32 | walletd [flags] [action] 33 | 34 | Run 'walletd' with no arguments to start the blockchain node and API server. 35 | 36 | Actions: 37 | version print walletd version 38 | seed generate a recovery phrase 39 | mine run CPU miner` 40 | 41 | versionUsage = `Usage: 42 | walletd version 43 | 44 | Prints the version of the walletd binary. 45 | ` 46 | seedUsage = `Usage: 47 | walletd seed 48 | 49 | Generates a secure BIP-39 recovery phrase. 50 | ` 51 | mineUsage = `Usage: 52 | walletd mine 53 | 54 | Runs a CPU miner. Not intended for production use. 55 | ` 56 | ) 57 | 58 | var cfg = config.Config{ 59 | Name: "walletd", 60 | Directory: os.Getenv(dataDirEnvVar), 61 | AutoOpenWebUI: true, 62 | HTTP: config.HTTP{ 63 | Address: "localhost:9980", 64 | Password: os.Getenv(apiPasswordEnvVar), 65 | PublicEndpoints: false, 66 | }, 67 | Syncer: config.Syncer{ 68 | Address: ":9981", 69 | Bootstrap: true, 70 | }, 71 | Consensus: config.Consensus{ 72 | Network: "mainnet", 73 | }, 74 | Index: config.Index{ 75 | Mode: wallet.IndexModePersonal, 76 | BatchSize: 10, 77 | }, 78 | Log: config.Log{ 79 | Level: zap.NewAtomicLevelAt(zap.InfoLevel), 80 | File: config.LogFile{ 81 | Enabled: true, 82 | Format: "json", 83 | Path: os.Getenv(logFileEnvVar), 84 | }, 85 | StdOut: config.StdOut{ 86 | Enabled: true, 87 | Format: "human", 88 | EnableANSI: runtime.GOOS != "windows", 89 | }, 90 | }, 91 | } 92 | 93 | func mustSetAPIPassword() { 94 | if cfg.HTTP.Password != "" { 95 | return 96 | } 97 | 98 | // retry until a valid API password is entered 99 | for { 100 | fmt.Println("Please choose a password to unlock walletd.") 101 | fmt.Println("This password will be required to access the admin UI in your web browser.") 102 | fmt.Println("(The password must be at least 4 characters.)") 103 | cfg.HTTP.Password = readPasswordInput("Enter password") 104 | if len(cfg.HTTP.Password) >= 4 { 105 | break 106 | } 107 | 108 | fmt.Println(wrapANSI("\033[31m", "Password must be at least 4 characters!", "\033[0m")) 109 | fmt.Println("") 110 | } 111 | } 112 | 113 | // checkFatalError prints an error message to stderr and exits with a 1 exit code. If err is nil, this is a no-op. 114 | func checkFatalError(context string, err error) { 115 | if err == nil { 116 | return 117 | } 118 | os.Stderr.WriteString(fmt.Sprintf("%s: %s\n", context, err)) 119 | os.Exit(1) 120 | } 121 | 122 | // tryLoadConfig tries to load the config file. It will try multiple locations 123 | // based on GOOS starting with PWD/walletd.yml. If the file does not exist, it will 124 | // try the next location. If an error occurs while loading the file, it will 125 | // print the error and exit. If the config is successfully loaded, the path to 126 | // the config file is returned. 127 | func tryLoadConfig() string { 128 | for _, fp := range tryConfigPaths() { 129 | if err := config.LoadFile(fp, &cfg); err == nil { 130 | return fp 131 | } else if !errors.Is(err, os.ErrNotExist) { 132 | checkFatalError("failed to load config file", err) 133 | } 134 | } 135 | return "" 136 | } 137 | 138 | // jsonEncoder returns a zapcore.Encoder that encodes logs as JSON intended for 139 | // parsing. 140 | func jsonEncoder() zapcore.Encoder { 141 | cfg := zap.NewProductionEncoderConfig() 142 | cfg.EncodeTime = zapcore.RFC3339TimeEncoder 143 | cfg.TimeKey = "timestamp" 144 | return zapcore.NewJSONEncoder(cfg) 145 | } 146 | 147 | // humanEncoder returns a zapcore.Encoder that encodes logs as human-readable 148 | // text. 149 | func humanEncoder(showColors bool) zapcore.Encoder { 150 | cfg := zap.NewProductionEncoderConfig() 151 | cfg.EncodeTime = zapcore.RFC3339TimeEncoder 152 | cfg.EncodeDuration = zapcore.StringDurationEncoder 153 | 154 | if showColors { 155 | cfg.EncodeLevel = zapcore.CapitalColorLevelEncoder 156 | } else { 157 | cfg.EncodeLevel = zapcore.CapitalLevelEncoder 158 | } 159 | 160 | cfg.StacktraceKey = "" 161 | cfg.CallerKey = "" 162 | return zapcore.NewConsoleEncoder(cfg) 163 | } 164 | 165 | func initStdoutLog(colored bool, level zap.AtomicLevel) *zap.Logger { 166 | core := zapcore.NewCore(humanEncoder(colored), zapcore.Lock(os.Stdout), level) 167 | return zap.New(core, zap.AddCaller()) 168 | } 169 | 170 | func main() { 171 | log := initStdoutLog(cfg.Log.StdOut.EnableANSI, cfg.Log.Level) 172 | defer log.Sync() 173 | 174 | // attempt to load the config file, command line flags will override any 175 | // values set in the config file 176 | configPath := tryLoadConfig() 177 | if configPath != "" { 178 | log.Info("loaded config file", zap.String("path", configPath)) 179 | } 180 | // set the data directory to the default if it is not set 181 | cfg.Directory = defaultDataDirectory(cfg.Directory) 182 | 183 | indexModeStr := cfg.Index.Mode.String() 184 | 185 | var minerAddrStr string 186 | var minerBlocks int 187 | 188 | rootCmd := flagg.Root 189 | rootCmd.Usage = flagg.SimpleUsage(rootCmd, rootUsage) 190 | rootCmd.BoolVar(&cfg.Debug, "debug", cfg.Debug, "enable debug mode with additional profiling and mining endpoints") 191 | rootCmd.StringVar(&cfg.Directory, "dir", cfg.Directory, "directory to store node state in") 192 | rootCmd.StringVar(&cfg.HTTP.Address, "http", cfg.HTTP.Address, "address to serve API on") 193 | rootCmd.BoolVar(&cfg.HTTP.PublicEndpoints, "http.public", cfg.HTTP.PublicEndpoints, "disables auth on endpoints that should be publicly accessible when running walletd as a service") 194 | 195 | rootCmd.StringVar(&cfg.Syncer.Address, "addr", cfg.Syncer.Address, "p2p address to listen on") 196 | rootCmd.StringVar(&cfg.Consensus.Network, "network", cfg.Consensus.Network, "network to connect to; must be one of 'mainnet', 'zen', 'anagami', or the path to a custom network file for a local testnet") 197 | rootCmd.BoolVar(&cfg.Syncer.EnableUPnP, "upnp", cfg.Syncer.EnableUPnP, "attempt to forward ports and discover IP with UPnP") 198 | rootCmd.BoolVar(&cfg.Syncer.Bootstrap, "bootstrap", cfg.Syncer.Bootstrap, "attempt to bootstrap the network") 199 | 200 | rootCmd.StringVar(&indexModeStr, "index.mode", indexModeStr, "address index mode (personal, full, none)") 201 | rootCmd.IntVar(&cfg.Index.BatchSize, "index.batch", cfg.Index.BatchSize, "max number of blocks to index at a time. Increasing this will increase scan speed, but also increase memory and cpu usage.") 202 | 203 | rootCmd.TextVar(&cfg.Log.Level, "log.level", cfg.Log.Level, "log level (debug, info, warn, error)") 204 | rootCmd.BoolVar(&cfg.Log.File.Enabled, "log.file.enabled", cfg.Log.File.Enabled, "enable file logging") 205 | rootCmd.BoolVar(&cfg.Log.StdOut.Enabled, "log.stdout.enabled", cfg.Log.StdOut.Enabled, "enable stdout logging") 206 | 207 | versionCmd := flagg.New("version", versionUsage) 208 | seedCmd := flagg.New("seed", seedUsage) 209 | configCmd := flagg.New("config", "interactively configure walletd") 210 | 211 | mineCmd := flagg.New("mine", mineUsage) 212 | mineCmd.IntVar(&minerBlocks, "n", -1, "mine this many blocks. If negative, mine indefinitely") 213 | mineCmd.StringVar(&minerAddrStr, "addr", "", "address to send block rewards to (required)") 214 | 215 | cmd := flagg.Parse(flagg.Tree{ 216 | Cmd: rootCmd, 217 | Sub: []flagg.Tree{ 218 | {Cmd: configCmd}, 219 | {Cmd: versionCmd}, 220 | {Cmd: seedCmd}, 221 | {Cmd: mineCmd}, 222 | }, 223 | }) 224 | 225 | switch cmd { 226 | case rootCmd: 227 | if len(cmd.Args()) != 0 { 228 | cmd.Usage() 229 | return 230 | } 231 | 232 | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM, syscall.SIGKILL) 233 | defer cancel() 234 | 235 | if cfg.Directory != "" { 236 | checkFatalError("failed to create data directory", os.MkdirAll(cfg.Directory, 0700)) 237 | } 238 | 239 | mustSetAPIPassword() 240 | 241 | checkFatalError("failed to parse index mode", cfg.Index.Mode.UnmarshalText([]byte(indexModeStr))) 242 | 243 | var logCores []zapcore.Core 244 | if cfg.Log.StdOut.Enabled { 245 | // if no log level is set for stdout, use the global log level 246 | if cfg.Log.StdOut.Level == (zap.AtomicLevel{}) { 247 | cfg.Log.StdOut.Level = cfg.Log.Level 248 | } 249 | 250 | var encoder zapcore.Encoder 251 | switch cfg.Log.StdOut.Format { 252 | case "json": 253 | encoder = jsonEncoder() 254 | default: // stdout defaults to human 255 | encoder = humanEncoder(cfg.Log.StdOut.EnableANSI) 256 | } 257 | 258 | // create the stdout logger 259 | logCores = append(logCores, zapcore.NewCore(encoder, zapcore.Lock(os.Stdout), cfg.Log.StdOut.Level)) 260 | } 261 | 262 | if cfg.Log.File.Enabled { 263 | // if no log level is set for file, use the global log level 264 | if cfg.Log.File.Level == (zap.AtomicLevel{}) { 265 | cfg.Log.File.Level = cfg.Log.Level 266 | } 267 | 268 | // normalize log path 269 | if cfg.Log.File.Path == "" { 270 | cfg.Log.File.Path = filepath.Join(cfg.Directory, "walletd.log") 271 | } 272 | 273 | // configure file logging 274 | var encoder zapcore.Encoder 275 | switch cfg.Log.File.Format { 276 | case "human": 277 | encoder = humanEncoder(false) // disable colors in file log 278 | default: // log file defaults to JSON 279 | encoder = jsonEncoder() 280 | } 281 | 282 | fileWriter, closeFn, err := zap.Open(cfg.Log.File.Path) 283 | checkFatalError("failed to open log file", err) 284 | defer closeFn() 285 | 286 | // create the file logger 287 | logCores = append(logCores, zapcore.NewCore(encoder, zapcore.Lock(fileWriter), cfg.Log.File.Level)) 288 | } 289 | 290 | var log *zap.Logger 291 | if len(logCores) == 1 { 292 | log = zap.New(logCores[0], zap.AddCaller()) 293 | } else { 294 | log = zap.New(zapcore.NewTee(logCores...), zap.AddCaller()) 295 | } 296 | defer log.Sync() 297 | 298 | // redirect stdlib log to zap 299 | zap.RedirectStdLog(log.Named("stdlib")) 300 | 301 | checkFatalError("failed to run node", runNode(ctx, cfg, log)) 302 | case versionCmd: 303 | if len(cmd.Args()) != 0 { 304 | cmd.Usage() 305 | return 306 | } 307 | fmt.Println("walletd", build.Version()) 308 | fmt.Println("Commit:", build.Commit()) 309 | fmt.Println("Build Date:", build.Time()) 310 | case seedCmd: 311 | if len(cmd.Args()) != 0 { 312 | cmd.Usage() 313 | return 314 | } 315 | recoveryPhrase := wallet.NewSeedPhrase() 316 | var seed [32]byte 317 | checkFatalError("failed to parse mnemonic phrase", wallet.SeedFromPhrase(&seed, recoveryPhrase)) 318 | addr := types.StandardUnlockHash(wallet.KeyFromSeed(&seed, 0).PublicKey()) 319 | 320 | fmt.Println("Recovery Phrase:", recoveryPhrase) 321 | fmt.Println("Address", addr) 322 | case configCmd: 323 | if len(cmd.Args()) != 0 { 324 | cmd.Usage() 325 | return 326 | } 327 | 328 | buildConfig(configPath) 329 | case mineCmd: 330 | if len(cmd.Args()) != 0 { 331 | cmd.Usage() 332 | return 333 | } 334 | 335 | minerAddr, err := types.ParseAddress(minerAddrStr) 336 | checkFatalError("failed to parse miner address", err) 337 | mustSetAPIPassword() 338 | c := api.NewClient("http://"+cfg.HTTP.Address+"/api", cfg.HTTP.Password) 339 | runCPUMiner(c, minerAddr, minerBlocks) 340 | } 341 | } 342 | -------------------------------------------------------------------------------- /cmd/walletd/miner.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "math/big" 7 | "time" 8 | 9 | "go.sia.tech/core/types" 10 | "go.sia.tech/coreutils" 11 | "go.sia.tech/walletd/v2/api" 12 | "lukechampine.com/frand" 13 | ) 14 | 15 | func runCPUMiner(c *api.Client, minerAddr types.Address, n int) { 16 | log.Println("Started mining into", minerAddr) 17 | start := time.Now() 18 | 19 | var blocksFound int 20 | for { 21 | if n >= 0 && blocksFound >= n { 22 | break 23 | } 24 | elapsed := time.Since(start) 25 | cs, err := c.ConsensusTipState() 26 | checkFatalError("failed to get consensus tip state:", err) 27 | d, _ := new(big.Int).SetString(cs.Difficulty.String(), 10) 28 | d.Mul(d, big.NewInt(int64(1+elapsed))) 29 | fmt.Printf("\rMining block %4v...(%.2f blocks/day), difficulty %v)", cs.Index.Height+1, float64(blocksFound)*float64(24*time.Hour)/float64(elapsed), cs.Difficulty) 30 | 31 | _, txns, v2txns, err := c.TxpoolTransactions() 32 | checkFatalError("failed to get pool transactions:", err) 33 | b := types.Block{ 34 | ParentID: cs.Index.ID, 35 | Nonce: cs.NonceFactor() * frand.Uint64n(100), 36 | Timestamp: types.CurrentTimestamp(), 37 | MinerPayouts: []types.SiacoinOutput{{Address: minerAddr, Value: cs.BlockReward()}}, 38 | Transactions: txns, 39 | } 40 | for _, txn := range txns { 41 | b.MinerPayouts[0].Value = b.MinerPayouts[0].Value.Add(txn.TotalFees()) 42 | } 43 | for _, txn := range v2txns { 44 | b.MinerPayouts[0].Value = b.MinerPayouts[0].Value.Add(txn.MinerFee) 45 | } 46 | if len(v2txns) > 0 || cs.Index.Height+1 >= cs.Network.HardforkV2.RequireHeight { 47 | b.V2 = &types.V2BlockData{ 48 | Height: cs.Index.Height + 1, 49 | Transactions: v2txns, 50 | } 51 | b.V2.Commitment = cs.Commitment(b.MinerPayouts[0].Address, b.Transactions, b.V2Transactions()) 52 | } 53 | if !coreutils.FindBlockNonce(cs, &b, time.Minute) { 54 | continue 55 | } 56 | blocksFound++ 57 | index := types.ChainIndex{Height: cs.Index.Height + 1, ID: b.ID()} 58 | tip, err := c.ConsensusTip() 59 | checkFatalError("failed to get consensus tip:", err) 60 | if tip != cs.Index { 61 | fmt.Printf("\nMined %v but tip changed, starting over\n", index) 62 | } else if err := c.SyncerBroadcastBlock(b); err != nil { 63 | fmt.Printf("\nMined invalid block: %v\n", err) 64 | } else if b.V2 == nil { 65 | fmt.Printf("\nFound v1 block %v\n", index) 66 | } else { 67 | fmt.Printf("\nFound v2 block %v\n", index) 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /cmd/walletd/node.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "net" 9 | "net/http" 10 | "os" 11 | "path/filepath" 12 | "runtime" 13 | "strconv" 14 | "strings" 15 | "time" 16 | 17 | "go.sia.tech/core/consensus" 18 | "go.sia.tech/core/gateway" 19 | "go.sia.tech/core/types" 20 | "go.sia.tech/coreutils" 21 | "go.sia.tech/coreutils/chain" 22 | "go.sia.tech/coreutils/syncer" 23 | "go.sia.tech/walletd/v2/api" 24 | "go.sia.tech/walletd/v2/build" 25 | "go.sia.tech/walletd/v2/config" 26 | "go.sia.tech/walletd/v2/persist/sqlite" 27 | "go.sia.tech/walletd/v2/wallet" 28 | "go.sia.tech/web/walletd" 29 | "go.uber.org/zap" 30 | "lukechampine.com/upnp" 31 | ) 32 | 33 | func tryConfigPaths() []string { 34 | if str := os.Getenv(configFileEnvVar); str != "" { 35 | return []string{str} 36 | } 37 | 38 | paths := []string{ 39 | "walletd.yml", 40 | } 41 | if str := os.Getenv(dataDirEnvVar); str != "" { 42 | paths = append(paths, filepath.Join(str, "walletd.yml")) 43 | } 44 | 45 | switch runtime.GOOS { 46 | case "windows": 47 | paths = append(paths, filepath.Join(os.Getenv("APPDATA"), "walletd", "walletd.yml")) 48 | case "darwin": 49 | paths = append(paths, filepath.Join(os.Getenv("HOME"), "Library", "Application Support", "walletd", "walletd.yml")) 50 | case "linux", "freebsd", "openbsd": 51 | paths = append(paths, 52 | filepath.Join(string(filepath.Separator), "etc", "walletd", "walletd.yml"), 53 | filepath.Join(string(filepath.Separator), "var", "lib", "walletd", "walletd.yml"), // old default for the Linux service 54 | ) 55 | } 56 | return paths 57 | } 58 | 59 | func defaultDataDirectory(fp string) string { 60 | // use the provided path if it's not empty 61 | if fp != "" { 62 | return fp 63 | } 64 | 65 | // check for databases in the current directory 66 | if _, err := os.Stat("walletd.db"); err == nil { 67 | return "." 68 | } else if _, err := os.Stat("walletd.sqlite3"); err == nil { 69 | return "." 70 | } 71 | 72 | // default to the operating system's application directory 73 | switch runtime.GOOS { 74 | case "windows": 75 | return filepath.Join(os.Getenv("APPDATA"), "walletd") 76 | case "darwin": 77 | return filepath.Join(os.Getenv("HOME"), "Library", "Application Support", "walletd") 78 | case "linux", "freebsd", "openbsd": 79 | return filepath.Join(string(filepath.Separator), "var", "lib", "walletd") 80 | default: 81 | return "." 82 | } 83 | } 84 | 85 | func setupUPNP(ctx context.Context, port uint16, log *zap.Logger) (string, error) { 86 | ctx, cancel := context.WithTimeout(ctx, 5*time.Second) 87 | defer cancel() 88 | d, err := upnp.Discover(ctx) 89 | if err != nil { 90 | return "", fmt.Errorf("couldn't discover UPnP router: %w", err) 91 | } else if !d.IsForwarded(port, "TCP") { 92 | if err := d.Forward(uint16(port), "TCP", "walletd"); err != nil { 93 | log.Debug("couldn't forward port", zap.Error(err)) 94 | } else { 95 | log.Debug("upnp: forwarded p2p port", zap.Uint16("port", port)) 96 | } 97 | } 98 | return d.ExternalIP() 99 | } 100 | 101 | // startLocalhostListener https://github.com/SiaFoundation/hostd/issues/202 102 | func startLocalhostListener(listenAddr string, log *zap.Logger) (l net.Listener, err error) { 103 | addr, port, err := net.SplitHostPort(listenAddr) 104 | if err != nil { 105 | return nil, fmt.Errorf("failed to parse API address: %w", err) 106 | } 107 | 108 | // if the address is not localhost, listen on the address as-is 109 | if addr != "localhost" { 110 | return net.Listen("tcp", listenAddr) 111 | } 112 | 113 | // localhost fails on some new installs of Windows 11, so try a few 114 | // different addresses 115 | tryAddresses := []string{ 116 | net.JoinHostPort("localhost", port), // original address 117 | net.JoinHostPort("127.0.0.1", port), // IPv4 loopback 118 | net.JoinHostPort("::1", port), // IPv6 loopback 119 | } 120 | 121 | for _, addr := range tryAddresses { 122 | l, err = net.Listen("tcp", addr) 123 | if err == nil { 124 | return 125 | } 126 | log.Debug("failed to listen on fallback address", zap.String("address", addr), zap.Error(err)) 127 | } 128 | return 129 | } 130 | 131 | func loadCustomNetwork(fp string) (*consensus.Network, types.Block, error) { 132 | f, err := os.Open(fp) 133 | if err != nil { 134 | return nil, types.Block{}, fmt.Errorf("failed to open network file: %w", err) 135 | } 136 | defer f.Close() 137 | 138 | var network struct { 139 | Network consensus.Network `json:"network" yaml:"network"` 140 | Genesis types.Block `json:"genesis" yaml:"genesis"` 141 | } 142 | 143 | if err := json.NewDecoder(f).Decode(&network); err != nil { 144 | return nil, types.Block{}, fmt.Errorf("failed to decode JSON network file: %w", err) 145 | } 146 | return &network.Network, network.Genesis, nil 147 | } 148 | 149 | // migrateConsensusDB checks if the consensus database needs to be migrated 150 | // to match the new v2 commitment. 151 | func migrateConsensusDB(fp string, n *consensus.Network, genesis types.Block, log *zap.Logger) error { 152 | bdb, err := coreutils.OpenBoltChainDB(fp) 153 | if err != nil { 154 | return fmt.Errorf("failed to open consensus database: %w", err) 155 | } 156 | defer bdb.Close() 157 | 158 | dbstore, tipState, err := chain.NewDBStore(bdb, n, genesis, chain.NewZapMigrationLogger(log.Named("chaindb"))) 159 | if err != nil { 160 | return fmt.Errorf("failed to create chain store: %w", err) 161 | } else if tipState.Index.Height < n.HardforkV2.AllowHeight { 162 | return nil // no migration needed, the chain is still on v1 163 | } 164 | 165 | log.Debug("checking for v2 commitment migration") 166 | b, _, ok := dbstore.Block(tipState.Index.ID) 167 | if !ok { 168 | return fmt.Errorf("failed to get tip block %q", tipState.Index) 169 | } else if b.V2 == nil { 170 | log.Debug("tip block is not a v2 block, skipping commitment migration") 171 | return nil 172 | } 173 | 174 | parentState, ok := dbstore.State(b.ParentID) 175 | if !ok { 176 | return fmt.Errorf("failed to get parent state for tip block %q", b.ParentID) 177 | } 178 | commitment := parentState.Commitment(b.MinerPayouts[0].Address, b.Transactions, b.V2Transactions()) 179 | log = log.With(zap.Stringer("tip", b.ID()), zap.Stringer("commitment", b.V2.Commitment), zap.Stringer("expected", commitment)) 180 | if b.V2.Commitment == commitment { 181 | log.Debug("tip block commitment matches parent state, no migration needed") 182 | return nil 183 | } 184 | // reset the database if the commitment is not a merkle root 185 | log.Debug("resetting consensus database for new v2 commitment") 186 | if err := bdb.Close(); err != nil { 187 | return fmt.Errorf("failed to close old consensus database: %w", err) 188 | } else if err := os.RemoveAll(fp); err != nil { 189 | return fmt.Errorf("failed to remove old consensus database: %w", err) 190 | } 191 | log.Debug("consensus database reset") 192 | return nil 193 | } 194 | 195 | func runNode(ctx context.Context, cfg config.Config, log *zap.Logger) error { 196 | store, err := sqlite.OpenDatabase(filepath.Join(cfg.Directory, "walletd.sqlite3"), sqlite.WithLog(log.Named("sqlite3"))) 197 | if err != nil { 198 | return fmt.Errorf("failed to open wallet database: %w", err) 199 | } 200 | defer store.Close() 201 | 202 | var network *consensus.Network 203 | var genesisBlock types.Block 204 | var bootstrapPeers []string 205 | switch cfg.Consensus.Network { 206 | case "mainnet": 207 | network, genesisBlock = chain.Mainnet() 208 | bootstrapPeers = syncer.MainnetBootstrapPeers 209 | case "zen": 210 | network, genesisBlock = chain.TestnetZen() 211 | bootstrapPeers = syncer.ZenBootstrapPeers 212 | case "anagami": 213 | network, genesisBlock = chain.TestnetAnagami() 214 | bootstrapPeers = syncer.AnagamiBootstrapPeers 215 | case "erravimus": 216 | network, genesisBlock = chain.TestnetErravimus() 217 | bootstrapPeers = syncer.ErravimusBootstrapPeers 218 | default: 219 | var err error 220 | network, genesisBlock, err = loadCustomNetwork(cfg.Consensus.Network) 221 | if errors.Is(err, os.ErrNotExist) { 222 | return errors.New("invalid network: must be one of 'mainnet', 'zen', or 'anagami'") 223 | } else if err != nil { 224 | return fmt.Errorf("failed to load custom network: %w", err) 225 | } 226 | } 227 | 228 | consensusPath := filepath.Join(cfg.Directory, "consensus.db") 229 | if err := migrateConsensusDB(consensusPath, network, genesisBlock, log.Named("migrate")); err != nil { 230 | return fmt.Errorf("failed to open consensus database: %w", err) 231 | } 232 | 233 | bdb, err := coreutils.OpenBoltChainDB(consensusPath) 234 | if err != nil { 235 | return fmt.Errorf("failed to open consensus database: %w", err) 236 | } 237 | defer bdb.Close() 238 | 239 | dbstore, tipState, err := chain.NewDBStore(bdb, network, genesisBlock, chain.NewZapMigrationLogger(log.Named("chaindb"))) 240 | if err != nil { 241 | return fmt.Errorf("failed to create chain store: %w", err) 242 | } 243 | cm := chain.NewManager(dbstore, tipState) 244 | 245 | syncerListener, err := net.Listen("tcp", cfg.Syncer.Address) 246 | if err != nil { 247 | return fmt.Errorf("failed to listen on %q: %w", cfg.Syncer.Address, err) 248 | } 249 | defer syncerListener.Close() 250 | 251 | httpListener, err := startLocalhostListener(cfg.HTTP.Address, log) 252 | if err != nil { 253 | return fmt.Errorf("failed to listen on %q: %w", cfg.HTTP.Address, err) 254 | } 255 | defer httpListener.Close() 256 | 257 | syncerAddr := syncerListener.Addr().String() 258 | if cfg.Syncer.EnableUPnP { 259 | _, portStr, _ := net.SplitHostPort(cfg.Syncer.Address) 260 | port, err := strconv.ParseUint(portStr, 10, 16) 261 | if err != nil { 262 | return fmt.Errorf("failed to parse syncer port: %w", err) 263 | } 264 | 265 | ip, err := setupUPNP(context.Background(), uint16(port), log) 266 | if err != nil { 267 | log.Warn("failed to set up UPnP", zap.Error(err)) 268 | } else { 269 | syncerAddr = net.JoinHostPort(ip, portStr) 270 | } 271 | } 272 | 273 | // peers will reject us if our hostname is empty or unspecified, so use loopback 274 | host, port, _ := net.SplitHostPort(syncerAddr) 275 | if ip := net.ParseIP(host); ip == nil || ip.IsUnspecified() { 276 | syncerAddr = net.JoinHostPort("127.0.0.1", port) 277 | } 278 | 279 | if cfg.Syncer.Bootstrap { 280 | for _, peer := range bootstrapPeers { 281 | if err := store.AddPeer(peer); err != nil { 282 | return fmt.Errorf("failed to add bootstrap peer %q: %w", peer, err) 283 | } 284 | } 285 | for _, peer := range cfg.Syncer.Peers { 286 | if err := store.AddPeer(peer); err != nil { 287 | return fmt.Errorf("failed to add peer %q: %w", peer, err) 288 | } 289 | } 290 | } 291 | 292 | ps, err := sqlite.NewPeerStore(store) 293 | if err != nil { 294 | return fmt.Errorf("failed to create peer store: %w", err) 295 | } 296 | 297 | header := gateway.Header{ 298 | GenesisID: genesisBlock.ID(), 299 | UniqueID: gateway.GenerateUniqueID(), 300 | NetAddress: syncerAddr, 301 | } 302 | 303 | s := syncer.New(syncerListener, cm, ps, header, syncer.WithLogger(log.Named("syncer"))) 304 | defer s.Close() 305 | go s.Run() 306 | 307 | wm, err := wallet.NewManager(cm, store, wallet.WithLogger(log.Named("wallet")), wallet.WithIndexMode(cfg.Index.Mode), wallet.WithSyncBatchSize(cfg.Index.BatchSize)) 308 | if err != nil { 309 | return fmt.Errorf("failed to create wallet manager: %w", err) 310 | } 311 | defer wm.Close() 312 | 313 | apiOpts := []api.ServerOption{ 314 | api.WithLogger(log.Named("api")), 315 | api.WithPublicEndpoints(cfg.HTTP.PublicEndpoints), 316 | api.WithBasicAuth(cfg.HTTP.Password), 317 | } 318 | if cfg.Debug { 319 | apiOpts = append(apiOpts, api.WithDebug()) 320 | } 321 | api := api.NewServer(cm, s, wm, apiOpts...) 322 | web := walletd.Handler() 323 | server := &http.Server{ 324 | Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 325 | if strings.HasPrefix(r.URL.Path, "/api") { 326 | r.URL.Path = strings.TrimPrefix(r.URL.Path, "/api") 327 | api.ServeHTTP(w, r) 328 | return 329 | } 330 | web.ServeHTTP(w, r) 331 | }), 332 | ReadTimeout: 10 * time.Second, 333 | } 334 | defer server.Close() 335 | go server.Serve(httpListener) 336 | 337 | log.Info("node started", zap.String("network", network.Name), zap.Stringer("syncer", syncerListener.Addr()), zap.Stringer("http", httpListener.Addr()), zap.String("version", build.Version()), zap.String("commit", build.Commit())) 338 | <-ctx.Done() 339 | log.Info("shutting down") 340 | return nil 341 | } 342 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | 8 | "go.sia.tech/walletd/v2/wallet" 9 | "go.uber.org/zap" 10 | "gopkg.in/yaml.v3" 11 | ) 12 | 13 | type ( 14 | // HTTP contains the configuration for the HTTP server. 15 | HTTP struct { 16 | Address string `yaml:"address,omitempty"` 17 | Password string `yaml:"password,omitempty"` 18 | PublicEndpoints bool `yaml:"publicEndpoints,omitempty"` 19 | } 20 | 21 | // Syncer contains the configuration for the consensus set syncer. 22 | Syncer struct { 23 | Address string `yaml:"address,omitempty"` 24 | Bootstrap bool `yaml:"bootstrap,omitempty"` 25 | EnableUPnP bool `yaml:"enableUPnP,omitempty"` 26 | Peers []string `yaml:"peers,omitempty"` 27 | } 28 | 29 | // Consensus contains the configuration for the consensus set. 30 | Consensus struct { 31 | Network string `yaml:"network,omitempty"` 32 | } 33 | 34 | // Index contains the configuration for the blockchain indexer 35 | Index struct { 36 | Mode wallet.IndexMode `yaml:"mode,omitempty"` 37 | BatchSize int `yaml:"batchSize,omitempty"` 38 | } 39 | 40 | // LogFile configures the file output of the logger. 41 | LogFile struct { 42 | Enabled bool `yaml:"enabled,omitempty"` 43 | Level zap.AtomicLevel `yaml:"level,omitempty"` // override the file log level 44 | Format string `yaml:"format,omitempty"` 45 | // Path is the path of the log file. 46 | Path string `yaml:"path,omitempty"` 47 | } 48 | 49 | // StdOut configures the standard output of the logger. 50 | StdOut struct { 51 | Level zap.AtomicLevel `yaml:"level,omitempty"` // override the stdout log level 52 | Enabled bool `yaml:"enabled,omitempty"` 53 | Format string `yaml:"format,omitempty"` 54 | EnableANSI bool `yaml:"enableANSI,omitempty"` //nolint:tagliatelle 55 | } 56 | 57 | // Log contains the configuration for the logger. 58 | Log struct { 59 | Level zap.AtomicLevel `yaml:"level,omitempty"` // global log level 60 | StdOut StdOut `yaml:"stdout,omitempty"` 61 | File LogFile `yaml:"file,omitempty"` 62 | } 63 | 64 | // Config contains the configuration for the host. 65 | Config struct { 66 | Name string `yaml:"name,omitempty"` 67 | Directory string `yaml:"directory,omitempty"` 68 | AutoOpenWebUI bool `yaml:"autoOpenWebUI,omitempty"` 69 | Debug bool `yaml:"debug,omitempty"` 70 | 71 | HTTP HTTP `yaml:"http,omitempty"` 72 | Consensus Consensus `yaml:"consensus,omitempty"` 73 | Syncer Syncer `yaml:"syncer,omitempty"` 74 | Log Log `yaml:"log,omitempty"` 75 | Index Index `yaml:"index,omitempty"` 76 | } 77 | ) 78 | 79 | // LoadFile loads the configuration from the provided file path. 80 | // If the file does not exist, an error is returned. 81 | // If the file exists but cannot be decoded, the function will attempt 82 | // to upgrade the config file. 83 | func LoadFile(fp string, cfg *Config) error { 84 | buf, err := os.ReadFile(fp) 85 | if err != nil { 86 | return fmt.Errorf("failed to read config file: %w", err) 87 | } 88 | 89 | r := bytes.NewReader(buf) 90 | dec := yaml.NewDecoder(r) 91 | dec.KnownFields(true) 92 | 93 | if err := dec.Decode(cfg); err != nil { 94 | return fmt.Errorf("failed to decode config file: %w", err) 95 | } 96 | return nil 97 | } 98 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module go.sia.tech/walletd/v2 // v2.9.0 2 | 3 | go 1.24.2 4 | 5 | require ( 6 | github.com/mattn/go-sqlite3 v1.14.28 7 | go.sia.tech/core v0.13.1 8 | go.sia.tech/coreutils v0.15.2 9 | go.sia.tech/jape v0.14.0 10 | go.sia.tech/web/walletd v0.29.3 11 | go.uber.org/zap v1.27.0 12 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 13 | golang.org/x/term v0.32.0 14 | gopkg.in/yaml.v3 v3.0.1 15 | lukechampine.com/flagg v1.1.1 16 | lukechampine.com/frand v1.5.1 17 | lukechampine.com/upnp v0.3.0 18 | ) 19 | 20 | require ( 21 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect 22 | github.com/google/pprof v0.0.0-20230821062121-407c9e7a662f // indirect 23 | github.com/julienschmidt/httprouter v1.3.0 // indirect 24 | github.com/onsi/ginkgo/v2 v2.12.0 // indirect 25 | github.com/quic-go/qpack v0.5.1 // indirect 26 | github.com/quic-go/quic-go v0.52.0 // indirect 27 | github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66 // indirect 28 | go.etcd.io/bbolt v1.4.0 // indirect 29 | go.sia.tech/mux v1.4.0 // indirect 30 | go.sia.tech/web v0.0.0-20240610131903-5611d44a533e // indirect 31 | go.uber.org/mock v0.5.0 // indirect 32 | go.uber.org/multierr v1.11.0 // indirect 33 | golang.org/x/crypto v0.38.0 // indirect 34 | golang.org/x/mod v0.24.0 // indirect 35 | golang.org/x/net v0.40.0 // indirect 36 | golang.org/x/sync v0.14.0 // indirect 37 | golang.org/x/sys v0.33.0 // indirect 38 | golang.org/x/text v0.25.0 // indirect 39 | golang.org/x/tools v0.33.0 // indirect 40 | ) 41 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk= 5 | github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= 6 | github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= 7 | github.com/go-logr/logr v1.2.4/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 8 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= 9 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= 10 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 11 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 12 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 13 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 14 | github.com/google/pprof v0.0.0-20230821062121-407c9e7a662f h1:pDhu5sgp8yJlEF/g6osliIIpF9K4F5jvkULXa4daRDQ= 15 | github.com/google/pprof v0.0.0-20230821062121-407c9e7a662f/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= 16 | github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= 17 | github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= 18 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 19 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 20 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 21 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 22 | github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= 23 | github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 24 | github.com/onsi/ginkgo/v2 v2.12.0 h1:UIVDowFPwpg6yMUpPjGkYvf06K3RAiJXUhCxEwQVHRI= 25 | github.com/onsi/ginkgo/v2 v2.12.0/go.mod h1:ZNEzXISYlqpb8S36iN71ifqLi3vVD1rVJGvWRCJOUpQ= 26 | github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= 27 | github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= 28 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 29 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 30 | github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= 31 | github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= 32 | github.com/quic-go/quic-go v0.52.0 h1:/SlHrCRElyaU6MaEPKqKr9z83sBg2v4FLLvWM+Z47pA= 33 | github.com/quic-go/quic-go v0.52.0/go.mod h1:MFlGGpcpJqRAfmYi6NC2cptDPSxRWTOGNuP4wqrWmzQ= 34 | github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66 h1:4WFk6u3sOT6pLa1kQ50ZVdm8BQFgJNA117cepZxtLIg= 35 | github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66/go.mod h1:Vp72IJajgeOL6ddqrAhmp7IM9zbTcgkQxD/YdxrVwMw= 36 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 37 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 38 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 39 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 40 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 41 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 42 | go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk= 43 | go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk= 44 | go.sia.tech/core v0.13.1 h1:dBKzZBhWZsgdV7qZa6qiaZtTDj5evvSqYWpeGNenlRI= 45 | go.sia.tech/core v0.13.1/go.mod h1:oMOgHT4bf9VSXUCOgtt9w4MFns/pY0LRUgwyMXdxW5w= 46 | go.sia.tech/coreutils v0.15.2 h1:2oEe8wpsmU5WVNfe0x75URhno+lPSXc+ozRtZNgjzu4= 47 | go.sia.tech/coreutils v0.15.2/go.mod h1:Kz/VQViqymnR1EW7DDdKrQru8dMFxiY44/qmjriilWs= 48 | go.sia.tech/jape v0.14.0 h1:hyocTKqvcji+rC1vDE1djINlpErQQVDS6zoLMmxW3Xs= 49 | go.sia.tech/jape v0.14.0/go.mod h1:tONxoKrNr0iQWzBCygwlTkGoGjuEhyVpLGInvGd2mGY= 50 | go.sia.tech/mux v1.4.0 h1:LgsLHtn7l+25MwrgaPaUCaS8f2W2/tfvHIdXps04sVo= 51 | go.sia.tech/mux v1.4.0/go.mod h1:iNFi9ifFb2XhuD+LF4t2HBb4Mvgq/zIPKqwXU/NlqHA= 52 | go.sia.tech/web v0.0.0-20240610131903-5611d44a533e h1:oKDz6rUExM4a4o6n/EXDppsEka2y/+/PgFOZmHWQRSI= 53 | go.sia.tech/web v0.0.0-20240610131903-5611d44a533e/go.mod h1:4nyDlycPKxTlCqvOeRO0wUfXxyzWCEE7+2BRrdNqvWk= 54 | go.sia.tech/web/walletd v0.29.3 h1:65Jl/2qAH+BECam3rJ6bp/GIONMct6XjClO1f7IjfYo= 55 | go.sia.tech/web/walletd v0.29.3/go.mod h1:VkWPLolV88EeAlGzTxSktwQRQ5+MZdkWan0N4d5aCZ8= 56 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 57 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 58 | go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= 59 | go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= 60 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 61 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 62 | go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= 63 | go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 64 | golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= 65 | golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 66 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= 67 | golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= 68 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= 69 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 70 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 71 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 72 | golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 73 | golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 74 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 75 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 76 | golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= 77 | golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= 78 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 79 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 80 | golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= 81 | golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= 82 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 83 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 84 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 85 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 86 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 87 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 88 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 89 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 90 | lukechampine.com/flagg v1.1.1 h1:jB5oL4D5zSUrzm5og6dDEi5pnrTF1poKfC7KE1lLsqc= 91 | lukechampine.com/flagg v1.1.1/go.mod h1:a9ZuZu5LSPXELWSJrabRD00ort+lDXSOQu34xWgEoDI= 92 | lukechampine.com/frand v1.5.1 h1:fg0eRtdmGFIxhP5zQJzM1lFDbD6CUfu/f+7WgAZd5/w= 93 | lukechampine.com/frand v1.5.1/go.mod h1:4VstaWc2plN4Mjr10chUD46RAVGWhpkZ5Nja8+Azp0Q= 94 | lukechampine.com/upnp v0.3.0 h1:UVCD6eD6fmJmwak6DVE3vGN+L46Fk8edTcC6XYCb6C4= 95 | lukechampine.com/upnp v0.3.0/go.mod h1:sOuF+fGSDKjpUm6QI0mfb82ScRrhj8bsqsD78O5nK1k= 96 | -------------------------------------------------------------------------------- /internal/testutil/testutil.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "path/filepath" 7 | "testing" 8 | "time" 9 | 10 | "go.sia.tech/core/consensus" 11 | "go.sia.tech/core/gateway" 12 | "go.sia.tech/core/types" 13 | "go.sia.tech/coreutils/chain" 14 | "go.sia.tech/coreutils/syncer" 15 | "go.sia.tech/coreutils/testutil" 16 | "go.sia.tech/walletd/v2/persist/sqlite" 17 | "go.uber.org/zap" 18 | ) 19 | 20 | type ( 21 | // A ConsensusNode is a test harness for starting a bare-bones consensus node. 22 | ConsensusNode struct { 23 | Store *sqlite.Store 24 | Chain *chain.Manager 25 | Syncer *MockSyncer 26 | } 27 | 28 | // MockSyncer is a no-op syncer implementation 29 | MockSyncer struct{} 30 | ) 31 | 32 | // WaitForSync waits for the store to sync to the current tip of the chain manager. 33 | func (cn *ConsensusNode) WaitForSync(tb testing.TB) { 34 | tb.Helper() 35 | 36 | for i := 0; i < 1000; i++ { 37 | select { 38 | case <-tb.Context().Done(): 39 | return 40 | default: 41 | index, err := cn.Store.LastCommittedIndex() 42 | if err != nil { 43 | tb.Fatal(err) 44 | } else if index == cn.Chain.Tip() { 45 | return 46 | } 47 | time.Sleep(10 * time.Millisecond) 48 | } 49 | } 50 | tb.Fatal("timeout waiting for sync") 51 | } 52 | 53 | // MineBlocks mines n blocks, sending the rewards to addr. 54 | func (cn *ConsensusNode) MineBlocks(tb testing.TB, addr types.Address, n int) { 55 | tb.Helper() 56 | 57 | for i := 0; i < n; i++ { 58 | select { 59 | case <-tb.Context().Done(): 60 | return 61 | default: 62 | testutil.MineBlocks(tb, cn.Chain, addr, 1) 63 | cn.WaitForSync(tb) 64 | } 65 | } 66 | } 67 | 68 | // NewConsensusNode creates a new ConsensusNode. 69 | func NewConsensusNode(tb testing.TB, n *consensus.Network, genesis types.Block, log *zap.Logger) *ConsensusNode { 70 | l, err := net.Listen("tcp", ":0") 71 | if err != nil { 72 | tb.Fatal(err) 73 | } 74 | tb.Cleanup(func() { l.Close() }) 75 | 76 | dbstore, tipState, err := chain.NewDBStore(chain.NewMemDB(), n, genesis, nil) 77 | if err != nil { 78 | tb.Fatal(err) 79 | } 80 | cm := chain.NewManager(dbstore, tipState) 81 | 82 | store, err := sqlite.OpenDatabase(filepath.Join(tb.TempDir(), "walletd.sqlite"), sqlite.WithLog(log.Named("sqlite3"))) 83 | if err != nil { 84 | tb.Fatal(err) 85 | } 86 | tb.Cleanup(func() { store.Close() }) 87 | 88 | return &ConsensusNode{ 89 | Store: store, 90 | Chain: cm, 91 | Syncer: &MockSyncer{}, 92 | } 93 | } 94 | 95 | // V1Network returns a test network and genesis block. 96 | func V1Network() (*consensus.Network, types.Block) { 97 | return testutil.Network() 98 | } 99 | 100 | // V2Network returns a test network and genesis block with early V2 hardforks 101 | func V2Network() (*consensus.Network, types.Block) { 102 | return testutil.V2Network() 103 | } 104 | 105 | // Addr is a no-op 106 | func (s *MockSyncer) Addr() string { 107 | return "" 108 | } 109 | 110 | // BroadcastHeader is a no-op 111 | func (s *MockSyncer) BroadcastHeader(bh types.BlockHeader) error { 112 | return nil 113 | } 114 | 115 | // BroadcastTransactionSet is a no-op 116 | func (s *MockSyncer) BroadcastTransactionSet(txns []types.Transaction) error { 117 | return nil 118 | } 119 | 120 | // BroadcastV2TransactionSet is a no-op 121 | func (s *MockSyncer) BroadcastV2TransactionSet(basis types.ChainIndex, txns []types.V2Transaction) error { 122 | return nil 123 | } 124 | 125 | // BroadcastV2BlockOutline is a no-op 126 | func (s *MockSyncer) BroadcastV2BlockOutline(outline gateway.V2BlockOutline) error { 127 | return nil 128 | } 129 | 130 | // Connect is a no-op 131 | func (s *MockSyncer) Connect(ctx context.Context, addr string) (*syncer.Peer, error) { 132 | return &syncer.Peer{}, nil 133 | } 134 | 135 | // PeerInfo is a no-op 136 | func (s *MockSyncer) PeerInfo(addr string) (syncer.PeerInfo, error) { 137 | return syncer.PeerInfo{}, nil 138 | } 139 | 140 | // Peers is a no-op 141 | func (s *MockSyncer) Peers() []*syncer.Peer { 142 | return nil 143 | } 144 | -------------------------------------------------------------------------------- /internal/threadgroup/threadgroup.go: -------------------------------------------------------------------------------- 1 | // Package threadgroup exposes a ThreadGroup object which can be used to 2 | // facilitate clean shutdown. A ThreadGroup is similar to a sync.WaitGroup, 3 | // but with two important additions: The ability to detect when shutdown has 4 | // been initiated, and protections against adding more threads after shutdown 5 | // has completed. 6 | // 7 | // ThreadGroup was designed with the following shutdown sequence in mind: 8 | // 9 | // 1. Call Stop, signaling that shutdown has begun. After Stop is called, no 10 | // new goroutines should be created. 11 | // 12 | // 2. Wait for Stop to return. When Stop returns, all goroutines should have 13 | // returned. 14 | // 15 | // 3. Free any resources used by the goroutines. 16 | package threadgroup 17 | 18 | import ( 19 | "context" 20 | "errors" 21 | "sync" 22 | ) 23 | 24 | type ( 25 | // A ThreadGroup is a sync.WaitGroup with additional functionality for 26 | // facilitating clean shutdown. 27 | ThreadGroup struct { 28 | mu sync.Mutex 29 | wg sync.WaitGroup 30 | closed chan struct{} 31 | } 32 | ) 33 | 34 | // ErrClosed is returned when the threadgroup has already been stopped 35 | var ErrClosed = errors.New("threadgroup closed") 36 | 37 | // Done returns a channel that will be closed when the threadgroup is stopped 38 | func (tg *ThreadGroup) Done() <-chan struct{} { 39 | return tg.closed 40 | } 41 | 42 | // Add adds a new thread to the group, done must be called to signal that the 43 | // thread is done. Returns ErrClosed if the threadgroup is already closed. 44 | func (tg *ThreadGroup) Add() (func(), error) { 45 | tg.mu.Lock() 46 | defer tg.mu.Unlock() 47 | select { 48 | case <-tg.closed: 49 | return nil, ErrClosed 50 | default: 51 | } 52 | tg.wg.Add(1) 53 | return func() { tg.wg.Done() }, nil 54 | } 55 | 56 | // WithContext returns a copy of the parent context. The returned context will 57 | // be cancelled if the parent context is cancelled or if the threadgroup is 58 | // stopped. 59 | func (tg *ThreadGroup) WithContext(parent context.Context) (context.Context, context.CancelFunc) { 60 | // wrap the parent context in a cancellable context 61 | ctx, cancel := context.WithCancel(parent) 62 | // start a goroutine to wait for either the parent context being cancelled 63 | // or the threagroup being stopped 64 | go func() { 65 | select { 66 | case <-ctx.Done(): 67 | case <-tg.closed: 68 | } 69 | cancel() // threadgroup is stopping or context cancelled, cancel the context 70 | }() 71 | return ctx, cancel 72 | } 73 | 74 | // AddWithContext adds a new thread to the group and returns a copy of the parent 75 | // context. It is a convenience function combining Add and WithContext. 76 | func (tg *ThreadGroup) AddWithContext(parent context.Context) (context.Context, context.CancelFunc, error) { 77 | // try to add to the group 78 | done, err := tg.Add() 79 | if err != nil { 80 | return nil, nil, err 81 | } 82 | 83 | ctx, cancel := tg.WithContext(parent) 84 | var once sync.Once 85 | return ctx, func() { 86 | cancel() 87 | // it must be safe to call cancel multiple times, but it is not safe to 88 | // call done multiple times since it's decrementing the waitgroup 89 | once.Do(done) 90 | }, nil 91 | } 92 | 93 | // Stop stops accepting new threads and waits for all existing threads to close 94 | func (tg *ThreadGroup) Stop() { 95 | tg.mu.Lock() 96 | select { 97 | case <-tg.closed: 98 | default: 99 | close(tg.closed) 100 | } 101 | tg.mu.Unlock() 102 | tg.wg.Wait() 103 | } 104 | 105 | // New creates a new threadgroup 106 | func New() *ThreadGroup { 107 | return &ThreadGroup{ 108 | closed: make(chan struct{}), 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /internal/threadgroup/threadgroup_test.go: -------------------------------------------------------------------------------- 1 | package threadgroup 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestThreadgroup(t *testing.T) { 11 | tg := New() 12 | 13 | for i := 0; i < 10; i++ { 14 | done, err := tg.Add() 15 | if err != nil { 16 | t.Fatal(err) 17 | } 18 | time.AfterFunc(100*time.Millisecond, done) 19 | } 20 | start := time.Now() 21 | tg.Stop() 22 | if time.Since(start) < 100*time.Millisecond { 23 | t.Fatal("expected stop to wait for all threads to complete") 24 | } 25 | 26 | _, err := tg.Add() 27 | if !errors.Is(err, ErrClosed) { 28 | t.Fatalf("expected ErrClosed, got %v", err) 29 | } 30 | } 31 | 32 | func TestThreadgroupContext(t *testing.T) { 33 | tg := New() 34 | 35 | t.Run("context cancel", func(t *testing.T) { 36 | ctx, cancel, err := tg.AddWithContext(context.Background()) 37 | if err != nil { 38 | t.Fatal(err) 39 | } 40 | defer cancel() 41 | 42 | time.AfterFunc(100*time.Millisecond, cancel) 43 | 44 | select { 45 | case <-ctx.Done(): 46 | if !errors.Is(ctx.Err(), context.Canceled) { 47 | t.Fatalf("expected Canceled, got %v", ctx.Err()) 48 | } 49 | case <-time.After(time.Second): 50 | t.Fatal("expected context to be cancelled") 51 | } 52 | }) 53 | 54 | t.Run("parent cancel", func(t *testing.T) { 55 | parentCtx, parentCancel := context.WithTimeout(context.Background(), 100*time.Millisecond) 56 | defer parentCancel() 57 | 58 | ctx, cancel, err := tg.AddWithContext(parentCtx) 59 | if err != nil { 60 | t.Fatal(err) 61 | } 62 | defer cancel() 63 | 64 | select { 65 | case <-ctx.Done(): 66 | if !errors.Is(ctx.Err(), context.DeadlineExceeded) { 67 | t.Fatalf("expected DeadlineExceeded, got %v", ctx.Err()) 68 | } 69 | case <-time.After(time.Second): 70 | t.Fatal("expected context to be cancelled") 71 | } 72 | }) 73 | 74 | t.Run("stop", func(t *testing.T) { 75 | for i := 0; i < 10; i++ { 76 | _, cancel, err := tg.AddWithContext(context.Background()) 77 | if err != nil { 78 | t.Fatal(err) 79 | } 80 | time.AfterFunc(100*time.Millisecond, cancel) 81 | } 82 | 83 | start := time.Now() 84 | tg.Stop() 85 | if time.Since(start) < 100*time.Millisecond { 86 | t.Fatal("expected threadgroup to wait until all threads complete") 87 | } 88 | }) 89 | } 90 | -------------------------------------------------------------------------------- /knope.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | changelog = "CHANGELOG.md" 3 | versioned_files = ["go.mod"] 4 | assets = "marker" 5 | 6 | [bot.releases] 7 | enabled = true 8 | 9 | [[workflows]] 10 | name = "document-change" 11 | 12 | [[workflows.steps]] 13 | type = "CreateChangeFile" 14 | 15 | [[workflows]] 16 | name = "prepare-release" 17 | 18 | [[workflows.steps]] 19 | type = "Command" 20 | command = "git switch -c release" 21 | 22 | [[workflows.steps]] 23 | type = "PrepareRelease" 24 | ignore_conventional_commits = true 25 | 26 | [[workflows.steps]] 27 | type = "Command" 28 | command = "git commit -m \"chore: prepare release $version\"" 29 | variables = { "$version" = "Version" } 30 | 31 | [[workflows.steps]] 32 | type = "Command" 33 | command = "git push --force --set-upstream origin release" 34 | 35 | [workflows.steps.variables] 36 | "$version" = "Version" 37 | 38 | [[workflows.steps]] 39 | type = "CreatePullRequest" 40 | base = "master" 41 | 42 | [workflows.steps.title] 43 | template = "chore: prepare release $version" 44 | variables = { "$version" = "Version" } 45 | 46 | [workflows.steps.body] 47 | template = "This PR was created automatically. Merging it will finalize the changelog for $version\n\n$changelog" 48 | variables = { "$changelog" = "ChangelogEntry", "$version" = "Version" } 49 | 50 | [[workflows]] 51 | name = "release" 52 | 53 | [[workflows.steps]] 54 | type = "Release" 55 | 56 | [github] 57 | owner = "SiaFoundation" 58 | repo = "walletd" 59 | -------------------------------------------------------------------------------- /persist/sqlite/address_test.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | 7 | "go.sia.tech/core/types" 8 | "go.sia.tech/walletd/v2/wallet" 9 | "go.uber.org/zap/zaptest" 10 | "lukechampine.com/frand" 11 | ) 12 | 13 | func TestCheckAddresses(t *testing.T) { 14 | log := zaptest.NewLogger(t) 15 | 16 | // generate a large number of random addresses 17 | addresses := make([]types.Address, 1000) 18 | for i := range addresses { 19 | addresses[i] = frand.Entropy256() 20 | } 21 | 22 | // create a new database 23 | db, err := OpenDatabase(filepath.Join(t.TempDir(), "walletd.sqlite"), WithLog(log.Named("sqlite3"))) 24 | if err != nil { 25 | t.Fatal(err) 26 | } 27 | defer db.Close() 28 | 29 | if known, err := db.CheckAddresses(addresses); err != nil { 30 | t.Fatal(err) 31 | } else if known { 32 | t.Fatal("expected no addresses to be known") 33 | } 34 | 35 | // add a random address to the database 36 | address := addresses[frand.Intn(len(addresses))] 37 | 38 | w, err := db.AddWallet(wallet.Wallet{}) 39 | if err != nil { 40 | t.Fatal(err) 41 | } else if err := db.AddWalletAddresses(w.ID, wallet.Address{ 42 | Address: address, 43 | }); err != nil { 44 | t.Fatal(err) 45 | } 46 | 47 | if known, err := db.CheckAddresses(addresses); err != nil { 48 | t.Fatal(err) 49 | } else if !known { 50 | t.Fatal("expected addresses to be known") 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /persist/sqlite/consensus_test.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | 7 | "go.sia.tech/core/consensus" 8 | "go.sia.tech/core/types" 9 | "go.sia.tech/coreutils" 10 | "go.sia.tech/coreutils/chain" 11 | "go.sia.tech/coreutils/testutil" 12 | "go.sia.tech/walletd/v2/wallet" 13 | "go.uber.org/zap/zaptest" 14 | ) 15 | 16 | func mineBlock(state consensus.State, txns []types.Transaction, minerAddr types.Address) types.Block { 17 | b := types.Block{ 18 | ParentID: state.Index.ID, 19 | Timestamp: types.CurrentTimestamp(), 20 | Transactions: txns, 21 | MinerPayouts: []types.SiacoinOutput{{Address: minerAddr, Value: state.BlockReward()}}, 22 | } 23 | for b.ID().CmpWork(state.ChildTarget) < 0 { 24 | b.Nonce += state.NonceFactor() 25 | } 26 | return b 27 | } 28 | 29 | func syncDB(tb testing.TB, store *Store, cm *chain.Manager) { 30 | index, err := store.LastCommittedIndex() 31 | if err != nil { 32 | tb.Fatalf("failed to get last committed index: %v", err) 33 | } 34 | for index != cm.Tip() { 35 | crus, caus, err := cm.UpdatesSince(index, 1000) 36 | if err != nil { 37 | tb.Fatalf("failed to subscribe to chain manager: %v", err) 38 | } else if err := store.UpdateChainState(crus, caus); err != nil { 39 | tb.Fatalf("failed to update chain state: %v", err) 40 | } 41 | 42 | switch { 43 | case len(caus) > 0: 44 | index = caus[len(caus)-1].State.Index 45 | case len(crus) > 0: 46 | index = crus[len(crus)-1].State.Index 47 | } 48 | } 49 | } 50 | 51 | func TestPruneSiacoins(t *testing.T) { 52 | log := zaptest.NewLogger(t) 53 | dir := t.TempDir() 54 | db, err := OpenDatabase(filepath.Join(dir, "walletd.sqlite3"), WithLog(log.Named("sqlite3")), WithRetainSpentElements(20)) 55 | if err != nil { 56 | t.Fatal(err) 57 | } 58 | defer db.Close() 59 | 60 | bdb, err := coreutils.OpenBoltChainDB(filepath.Join(dir, "consensus.db")) 61 | if err != nil { 62 | t.Fatal(err) 63 | } 64 | defer bdb.Close() 65 | 66 | // mine a single payout to the wallet 67 | pk := types.GeneratePrivateKey() 68 | addr := types.StandardUnlockHash(pk.PublicKey()) 69 | 70 | network, genesisBlock := testutil.Network() 71 | store, genesisState, err := chain.NewDBStore(bdb, network, genesisBlock, nil) 72 | if err != nil { 73 | t.Fatal(err) 74 | } 75 | 76 | cm := chain.NewManager(store, genesisState) 77 | 78 | // create a wallet 79 | w, err := db.AddWallet(wallet.Wallet{Name: "test"}) 80 | if err != nil { 81 | t.Fatal(err) 82 | } else if err := db.AddWalletAddresses(w.ID, wallet.Address{Address: addr}); err != nil { 83 | t.Fatal(err) 84 | } 85 | 86 | // mine a block to the wallet 87 | expectedPayout := cm.TipState().BlockReward() 88 | maturityHeight := cm.TipState().MaturityHeight() 89 | if err := cm.AddBlocks([]types.Block{mineBlock(cm.TipState(), nil, addr)}); err != nil { 90 | t.Fatal(err) 91 | } 92 | syncDB(t, db, cm) 93 | 94 | assertBalance := func(siacoin, immature types.Currency) { 95 | t.Helper() 96 | 97 | b, err := db.WalletBalance(w.ID) 98 | if err != nil { 99 | t.Fatalf("failed to get wallet balance: %v", err) 100 | } else if !b.ImmatureSiacoins.Equals(immature) { 101 | t.Fatalf("expected immature siacoin balance %v, got %v", immature, b.ImmatureSiacoins) 102 | } else if !b.Siacoins.Equals(siacoin) { 103 | t.Fatalf("expected siacoin balance %v, got %v", siacoin, b.Siacoins) 104 | } 105 | } 106 | 107 | assertUTXOs := func(spent int, unspent int) { 108 | t.Helper() 109 | 110 | var n int 111 | err := db.db.QueryRow(`SELECT COUNT(*) FROM siacoin_elements WHERE spent_index_id IS NOT NULL`).Scan(&n) 112 | if err != nil { 113 | t.Fatalf("failed to count spent siacoin elements: %v", err) 114 | } else if n != spent { 115 | t.Fatalf("expected %v spent siacoin elements, got %v", spent, n) 116 | } 117 | 118 | err = db.db.QueryRow(`SELECT COUNT(*) FROM siacoin_elements WHERE spent_index_id IS NULL`).Scan(&n) 119 | if err != nil { 120 | t.Fatalf("failed to count unspent siacoin elements: %v", err) 121 | } else if n != unspent { 122 | t.Fatalf("expected %v unspent siacoin elements, got %v", unspent, n) 123 | } 124 | } 125 | 126 | assertBalance(types.ZeroCurrency, expectedPayout) 127 | assertUTXOs(0, 1) 128 | 129 | // mine until the payout matures 130 | for range maturityHeight { 131 | if err := cm.AddBlocks([]types.Block{mineBlock(cm.TipState(), nil, types.VoidAddress)}); err != nil { 132 | t.Fatal(err) 133 | } 134 | } 135 | syncDB(t, db, cm) 136 | assertBalance(expectedPayout, types.ZeroCurrency) 137 | assertUTXOs(0, 1) 138 | 139 | // spend the utxo 140 | utxos, _, err := db.WalletSiacoinOutputs(w.ID, 0, 100) 141 | if err != nil { 142 | t.Fatalf("failed to get wallet siacoin outputs: %v", err) 143 | } 144 | 145 | txn := types.Transaction{ 146 | SiacoinInputs: []types.SiacoinInput{{ 147 | ParentID: types.SiacoinOutputID(utxos[0].ID), 148 | UnlockConditions: types.StandardUnlockConditions(pk.PublicKey()), 149 | }}, 150 | SiacoinOutputs: []types.SiacoinOutput{ 151 | {Value: utxos[0].SiacoinOutput.Value, Address: types.VoidAddress}, 152 | }, 153 | } 154 | 155 | sigHash := cm.TipState().WholeSigHash(txn, types.Hash256(utxos[0].ID), 0, 0, nil) 156 | sig := pk.SignHash(sigHash) 157 | txn.Signatures = append(txn.Signatures, types.TransactionSignature{ 158 | ParentID: types.Hash256(utxos[0].ID), 159 | CoveredFields: types.CoveredFields{WholeTransaction: true}, 160 | PublicKeyIndex: 0, 161 | Timelock: 0, 162 | Signature: sig[:], 163 | }) 164 | 165 | // mine a block with the transaction 166 | if err := cm.AddBlocks([]types.Block{mineBlock(cm.TipState(), []types.Transaction{txn}, types.VoidAddress)}); err != nil { 167 | t.Fatal(err) 168 | } 169 | syncDB(t, db, cm) 170 | 171 | // the utxo should now have 0 balance and 1 spent element 172 | assertBalance(types.ZeroCurrency, types.ZeroCurrency) 173 | assertUTXOs(1, 0) 174 | 175 | // mine until the element is pruned 176 | for i := uint64(0); i < db.spentElementRetentionBlocks-1; i++ { 177 | if err := cm.AddBlocks([]types.Block{mineBlock(cm.TipState(), nil, types.VoidAddress)}); err != nil { 178 | t.Fatal(err) 179 | } 180 | syncDB(t, db, cm) 181 | assertUTXOs(1, 0) // check that the element is not pruned early 182 | } 183 | 184 | // trigger the pruning 185 | if err := cm.AddBlocks([]types.Block{mineBlock(cm.TipState(), nil, types.VoidAddress)}); err != nil { 186 | t.Fatal(err) 187 | } 188 | syncDB(t, db, cm) 189 | assertUTXOs(0, 0) 190 | } 191 | 192 | func TestPruneSiafunds(t *testing.T) { 193 | log := zaptest.NewLogger(t) 194 | dir := t.TempDir() 195 | db, err := OpenDatabase(filepath.Join(dir, "walletd.sqlite3"), WithLog(log.Named("sqlite3"))) 196 | if err != nil { 197 | t.Fatal(err) 198 | } 199 | defer db.Close() 200 | 201 | bdb, err := coreutils.OpenBoltChainDB(filepath.Join(dir, "consensus.db")) 202 | if err != nil { 203 | t.Fatal(err) 204 | } 205 | defer bdb.Close() 206 | 207 | // mine a single payout to the wallet 208 | pk := types.GeneratePrivateKey() 209 | addr := types.StandardUnlockHash(pk.PublicKey()) 210 | 211 | network, genesisBlock := testutil.Network() 212 | // send the siafund airdrop to the wallet 213 | genesisBlock.Transactions[0].SiafundOutputs[0].Address = addr 214 | store, genesisState, err := chain.NewDBStore(bdb, network, genesisBlock, nil) 215 | if err != nil { 216 | t.Fatal(err) 217 | } 218 | 219 | cm := chain.NewManager(store, genesisState) 220 | 221 | // create a wallet 222 | w, err := db.AddWallet(wallet.Wallet{Name: "test"}) 223 | if err != nil { 224 | t.Fatal(err) 225 | } else if err := db.AddWalletAddresses(w.ID, wallet.Address{Address: addr}); err != nil { 226 | t.Fatal(err) 227 | } 228 | 229 | syncDB(t, db, cm) 230 | 231 | assertBalance := func(siafunds uint64) { 232 | t.Helper() 233 | 234 | b, err := db.WalletBalance(w.ID) 235 | if err != nil { 236 | t.Fatalf("failed to get wallet balance: %v", err) 237 | } else if b.Siafunds != siafunds { 238 | t.Fatalf("expected siafund balance %v, got %v", siafunds, b.ImmatureSiacoins) 239 | } 240 | } 241 | 242 | assertUTXOs := func(spent int, unspent int) { 243 | t.Helper() 244 | 245 | var n int 246 | err := db.db.QueryRow(`SELECT COUNT(*) FROM siafund_elements WHERE spent_index_id IS NOT NULL`).Scan(&n) 247 | if err != nil { 248 | t.Fatalf("failed to count spent siacoin elements: %v", err) 249 | } else if n != spent { 250 | t.Fatalf("expected %v spent siacoin elements, got %v", spent, n) 251 | } 252 | 253 | err = db.db.QueryRow(`SELECT COUNT(*) FROM siafund_elements WHERE spent_index_id IS NULL`).Scan(&n) 254 | if err != nil { 255 | t.Fatalf("failed to count unspent siacoin elements: %v", err) 256 | } else if n != unspent { 257 | t.Fatalf("expected %v unspent siacoin elements, got %v", unspent, n) 258 | } 259 | } 260 | 261 | assertBalance(cm.TipState().SiafundCount()) 262 | assertUTXOs(0, 1) 263 | 264 | // spend the utxo 265 | utxos, _, err := db.WalletSiafundOutputs(w.ID, 0, 100) 266 | if err != nil { 267 | t.Fatalf("failed to get wallet siacoin outputs: %v", err) 268 | } 269 | 270 | txn := types.Transaction{ 271 | SiafundInputs: []types.SiafundInput{{ 272 | ParentID: types.SiafundOutputID(utxos[0].ID), 273 | UnlockConditions: types.StandardUnlockConditions(pk.PublicKey()), 274 | }}, 275 | SiafundOutputs: []types.SiafundOutput{ 276 | {Value: utxos[0].SiafundOutput.Value, Address: types.VoidAddress}, 277 | }, 278 | } 279 | 280 | sigHash := cm.TipState().WholeSigHash(txn, types.Hash256(utxos[0].ID), 0, 0, nil) 281 | sig := pk.SignHash(sigHash) 282 | txn.Signatures = append(txn.Signatures, types.TransactionSignature{ 283 | ParentID: types.Hash256(utxos[0].ID), 284 | CoveredFields: types.CoveredFields{WholeTransaction: true}, 285 | PublicKeyIndex: 0, 286 | Timelock: 0, 287 | Signature: sig[:], 288 | }) 289 | 290 | // mine a block with the transaction 291 | if err := cm.AddBlocks([]types.Block{mineBlock(cm.TipState(), []types.Transaction{txn}, types.VoidAddress)}); err != nil { 292 | t.Fatal(err) 293 | } 294 | syncDB(t, db, cm) 295 | 296 | // the utxo should now have 0 balance and 1 spent element 297 | assertBalance(0) 298 | assertUTXOs(1, 0) 299 | 300 | // mine until the element is pruned 301 | for i := uint64(0); i < db.spentElementRetentionBlocks-1; i++ { 302 | if err := cm.AddBlocks([]types.Block{mineBlock(cm.TipState(), nil, types.VoidAddress)}); err != nil { 303 | t.Fatal(err) 304 | } 305 | syncDB(t, db, cm) // check that the element is not pruned early 306 | assertUTXOs(1, 0) 307 | } 308 | 309 | // the spent element should now be pruned 310 | if err := cm.AddBlocks([]types.Block{mineBlock(cm.TipState(), nil, types.VoidAddress)}); err != nil { 311 | t.Fatal(err) 312 | } 313 | syncDB(t, db, cm) 314 | assertUTXOs(0, 0) 315 | } 316 | -------------------------------------------------------------------------------- /persist/sqlite/encoding.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "bytes" 5 | "database/sql" 6 | "encoding/binary" 7 | "errors" 8 | "fmt" 9 | "time" 10 | 11 | "go.sia.tech/core/types" 12 | ) 13 | 14 | func encode(obj any) any { 15 | switch obj := obj.(type) { 16 | case types.Currency: 17 | // Currency is encoded as two 64-bit big-endian integers for sorting 18 | buf := make([]byte, 16) 19 | binary.BigEndian.PutUint64(buf, obj.Hi) 20 | binary.BigEndian.PutUint64(buf[8:], obj.Lo) 21 | return buf 22 | case []types.Hash256: 23 | var buf bytes.Buffer 24 | e := types.NewEncoder(&buf) 25 | types.EncodeSlice(e, obj) 26 | e.Flush() 27 | return buf.Bytes() 28 | case types.EncoderTo: 29 | var buf bytes.Buffer 30 | e := types.NewEncoder(&buf) 31 | obj.EncodeTo(e) 32 | e.Flush() 33 | return buf.Bytes() 34 | case uint64: 35 | b := make([]byte, 8) 36 | binary.LittleEndian.PutUint64(b, obj) 37 | return b 38 | case time.Time: 39 | return obj.Unix() 40 | default: 41 | panic(fmt.Sprintf("dbEncode: unsupported type %T", obj)) 42 | } 43 | } 44 | 45 | type decodable struct { 46 | v any 47 | } 48 | 49 | // Scan implements the sql.Scanner interface. 50 | func (d *decodable) Scan(src any) error { 51 | if src == nil { 52 | return errors.New("cannot scan nil into decodable") 53 | } 54 | 55 | switch src := src.(type) { 56 | case []byte: 57 | switch v := d.v.(type) { 58 | case *types.Currency: 59 | if len(src) != 16 { 60 | return fmt.Errorf("cannot scan %d bytes into Currency", len(src)) 61 | } 62 | v.Hi = binary.BigEndian.Uint64(src) 63 | v.Lo = binary.BigEndian.Uint64(src[8:]) 64 | case types.DecoderFrom: 65 | dec := types.NewBufDecoder(src) 66 | v.DecodeFrom(dec) 67 | return dec.Err() 68 | case *uint64: 69 | *v = binary.LittleEndian.Uint64(src) 70 | case *[]types.Hash256: 71 | dec := types.NewBufDecoder(src) 72 | types.DecodeSlice(dec, v) 73 | return dec.Err() 74 | default: 75 | return fmt.Errorf("cannot scan %T to %T", src, d.v) 76 | } 77 | return nil 78 | case int64: 79 | switch v := d.v.(type) { 80 | case *uint64: 81 | *v = uint64(src) 82 | case *time.Time: 83 | *v = time.Unix(src, 0).UTC() 84 | default: 85 | return fmt.Errorf("cannot scan %T to %T", src, d.v) 86 | } 87 | return nil 88 | default: 89 | return fmt.Errorf("cannot scan %T to %T", src, d.v) 90 | } 91 | } 92 | 93 | func decode(obj any) sql.Scanner { 94 | return &decodable{obj} 95 | } 96 | -------------------------------------------------------------------------------- /persist/sqlite/events.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "fmt" 7 | 8 | "go.sia.tech/core/types" 9 | "go.sia.tech/walletd/v2/wallet" 10 | ) 11 | 12 | // Events returns the events with the given event IDs. If an event is not found, 13 | // it is skipped. 14 | func (s *Store) Events(eventIDs []types.Hash256) (events []wallet.Event, err error) { 15 | err = s.transaction(func(tx *txn) error { 16 | var scanHeight uint64 17 | err := tx.QueryRow(`SELECT COALESCE(last_indexed_height, 0) FROM global_settings`).Scan(&scanHeight) 18 | if err != nil { 19 | return fmt.Errorf("failed to get last indexed height: %w", err) 20 | } 21 | 22 | // sqlite doesn't have easy support for IN clauses, use a statement since 23 | // the number of event IDs is likely to be small instead of dynamically 24 | // building the query 25 | const query = `SELECT 26 | ev.id, 27 | ev.event_id, 28 | ev.maturity_height, 29 | ev.date_created, 30 | ci.height, 31 | ci.block_id, 32 | ev.event_type, 33 | ev.event_data 34 | FROM events ev 35 | INNER JOIN event_addresses ea ON (ev.id = ea.event_id) 36 | INNER JOIN sia_addresses sa ON (ea.address_id = sa.id) 37 | INNER JOIN chain_indices ci ON (ev.chain_index_id = ci.id) 38 | WHERE ev.event_id = $1` 39 | 40 | stmt, err := tx.Prepare(query) 41 | if err != nil { 42 | return fmt.Errorf("failed to prepare statement: %w", err) 43 | } 44 | defer stmt.Close() 45 | 46 | events = make([]wallet.Event, 0, len(eventIDs)) 47 | for _, id := range eventIDs { 48 | event, _, err := scanEvent(stmt.QueryRow(encode(id)), scanHeight) 49 | if errors.Is(err, sql.ErrNoRows) { 50 | continue 51 | } else if err != nil { 52 | return fmt.Errorf("failed to query transaction %q: %w", id, err) 53 | } 54 | events = append(events, event) 55 | } 56 | return nil 57 | }) 58 | return 59 | } 60 | 61 | func decodeEventData[T wallet.EventPayout | 62 | wallet.EventV1Transaction | 63 | wallet.EventV2Transaction | 64 | wallet.EventV1ContractResolution | 65 | wallet.EventV2ContractResolution, TP interface { 66 | *T 67 | types.DecoderFrom 68 | }](dec *types.Decoder) T { 69 | v := new(T) 70 | TP(v).DecodeFrom(dec) 71 | return *v 72 | } 73 | 74 | func getEventsByID(tx *txn, eventIDs []int64) (events []wallet.Event, err error) { 75 | var scanHeight uint64 76 | err = tx.QueryRow(`SELECT COALESCE(last_indexed_height, 0) FROM global_settings`).Scan(&scanHeight) 77 | if err != nil { 78 | return nil, fmt.Errorf("failed to get last indexed height: %w", err) 79 | } 80 | 81 | stmt, err := tx.Prepare(`SELECT 82 | ev.id, 83 | ev.event_id, 84 | ev.maturity_height, 85 | ev.date_created, 86 | ci.height, 87 | ci.block_id, 88 | ev.event_type, 89 | ev.event_data 90 | FROM events ev 91 | INNER JOIN event_addresses ea ON (ev.id = ea.event_id) 92 | INNER JOIN sia_addresses sa ON (ea.address_id = sa.id) 93 | INNER JOIN chain_indices ci ON (ev.chain_index_id = ci.id) 94 | WHERE ev.id=$1`) 95 | if err != nil { 96 | return nil, fmt.Errorf("failed to prepare statement: %w", err) 97 | } 98 | defer stmt.Close() 99 | 100 | events = make([]wallet.Event, 0, len(eventIDs)) 101 | for i, id := range eventIDs { 102 | event, _, err := scanEvent(stmt.QueryRow(id), scanHeight) 103 | if errors.Is(err, sql.ErrNoRows) { 104 | continue 105 | } else if err != nil { 106 | return nil, fmt.Errorf("failed to query event %d: %w", i, err) 107 | } 108 | events = append(events, event) 109 | } 110 | return 111 | } 112 | 113 | func scanEvent(s scanner, scanHeight uint64) (ev wallet.Event, eventID int64, err error) { 114 | var eventBuf []byte 115 | err = s.Scan(&eventID, decode(&ev.ID), &ev.MaturityHeight, decode(&ev.Timestamp), &ev.Index.Height, decode(&ev.Index.ID), &ev.Type, &eventBuf) 116 | if err != nil { 117 | return 118 | } 119 | 120 | if scanHeight >= ev.Index.Height { 121 | ev.Confirmations = 1 + scanHeight - ev.Index.Height 122 | } 123 | 124 | dec := types.NewBufDecoder(eventBuf) 125 | switch ev.Type { 126 | case wallet.EventTypeV1Transaction: 127 | ev.Data = decodeEventData[wallet.EventV1Transaction](dec) 128 | case wallet.EventTypeV2Transaction: 129 | ev.Data = decodeEventData[wallet.EventV2Transaction](dec) 130 | case wallet.EventTypeV1ContractResolution: 131 | ev.Data = decodeEventData[wallet.EventV1ContractResolution](dec) 132 | case wallet.EventTypeV2ContractResolution: 133 | ev.Data = decodeEventData[wallet.EventV2ContractResolution](dec) 134 | case wallet.EventTypeSiafundClaim, wallet.EventTypeMinerPayout, wallet.EventTypeFoundationSubsidy: 135 | ev.Data = decodeEventData[wallet.EventPayout](dec) 136 | default: 137 | return wallet.Event{}, 0, fmt.Errorf("unknown event type: %q", ev.Type) 138 | } 139 | if err := dec.Err(); err != nil { 140 | return wallet.Event{}, 0, fmt.Errorf("failed to decode event data: %w", err) 141 | } 142 | 143 | return 144 | } 145 | -------------------------------------------------------------------------------- /persist/sqlite/events_test.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "testing" 7 | 8 | "go.sia.tech/core/types" 9 | "go.sia.tech/walletd/v2/wallet" 10 | "lukechampine.com/frand" 11 | ) 12 | 13 | func runBenchmarkWalletEvents(b *testing.B, name string, addresses, eventsPerAddress int) { 14 | b.Run(name, func(b *testing.B) { 15 | db, err := OpenDatabase(filepath.Join(b.TempDir(), "walletd.sqlite3")) 16 | if err != nil { 17 | b.Fatal(err) 18 | } 19 | defer db.Close() 20 | 21 | w, err := db.AddWallet(wallet.Wallet{ 22 | Name: "test", 23 | }) 24 | if err != nil { 25 | b.Fatal(err) 26 | } 27 | 28 | for i := 0; i < addresses; i++ { 29 | addr := types.Address(frand.Entropy256()) 30 | if err := db.AddWalletAddresses(w.ID, wallet.Address{Address: addr}); err != nil { 31 | b.Fatal(err) 32 | } 33 | 34 | err := db.transaction(func(tx *txn) error { 35 | utx := &updateTx{ 36 | indexMode: wallet.IndexModeFull, 37 | tx: tx, 38 | relevantAddresses: make(map[types.Address]bool), 39 | } 40 | 41 | events := make([]wallet.Event, eventsPerAddress) 42 | for i := range events { 43 | events[i] = wallet.Event{ 44 | ID: types.Hash256(frand.Entropy256()), 45 | MaturityHeight: uint64(i + 1), 46 | Relevant: []types.Address{addr}, 47 | Type: wallet.EventTypeV1Transaction, 48 | Data: wallet.EventV1Transaction{}, 49 | } 50 | } 51 | 52 | return utx.ApplyIndex(types.ChainIndex{ 53 | Height: uint64(i + 1), 54 | ID: types.BlockID(frand.Entropy256()), 55 | }, wallet.AppliedState{ 56 | Events: events, 57 | }) 58 | }) 59 | if err != nil { 60 | b.Fatal(err) 61 | } 62 | } 63 | 64 | b.ResetTimer() 65 | b.ReportAllocs() 66 | for i := 0; i < b.N; i++ { 67 | expectedEvents := eventsPerAddress * addresses 68 | if expectedEvents > 100 { 69 | expectedEvents = 100 70 | } 71 | 72 | events, err := db.WalletEvents(w.ID, 0, 100) 73 | if err != nil { 74 | b.Fatal(err) 75 | } else if len(events) != expectedEvents { 76 | b.Fatalf("expected %d events, got %d", expectedEvents, len(events)) 77 | } 78 | } 79 | }) 80 | } 81 | 82 | func BenchmarkWalletEvents(b *testing.B) { 83 | benchmarks := []struct { 84 | addresses int 85 | eventsPerAddress int 86 | }{ 87 | {1, 1}, 88 | {1, 10}, 89 | {1, 1000}, 90 | {10, 1}, 91 | {10, 1000}, 92 | {10, 100000}, 93 | {1000000, 0}, 94 | {1000000, 1}, 95 | {1000000, 10}, 96 | } 97 | for _, bm := range benchmarks { 98 | totalTransactions := bm.addresses * bm.eventsPerAddress 99 | runBenchmarkWalletEvents(b, fmt.Sprintf("wallet with %d addresses and %d transactions", bm.addresses, totalTransactions), bm.addresses, bm.eventsPerAddress) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /persist/sqlite/init.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "database/sql" 5 | _ "embed" // for init.sql 6 | "errors" 7 | "time" 8 | 9 | "fmt" 10 | 11 | "go.sia.tech/core/types" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | // init queries are run when the database is first created. 16 | // 17 | //go:embed init.sql 18 | var initDatabase string 19 | 20 | func initializeSettings(tx *txn, target int64) error { 21 | _, err := tx.Exec(`INSERT INTO global_settings (id, db_version, last_indexed_height, last_indexed_id, element_num_leaves) VALUES (0, ?, 0, ?, 0)`, target, encode(types.BlockID{})) 22 | return err 23 | } 24 | 25 | func (s *Store) initNewDatabase(target int64) error { 26 | return s.transaction(func(tx *txn) error { 27 | if _, err := tx.Exec(initDatabase); err != nil { 28 | return fmt.Errorf("failed to initialize database: %w", err) 29 | } else if err := initializeSettings(tx, target); err != nil { 30 | return fmt.Errorf("failed to initialize settings: %w", err) 31 | } 32 | return nil 33 | }) 34 | } 35 | 36 | func (s *Store) upgradeDatabase(current, target int64) error { 37 | log := s.log.Named("migrations").With(zap.Int64("target", target)) 38 | for ; current < target; current++ { 39 | version := current + 1 // initial schema is version 1, migration 0 is version 2, etc. 40 | log := log.With(zap.Int64("version", version)) 41 | start := time.Now() 42 | fn := migrations[current-1] 43 | err := s.transaction(func(tx *txn) error { 44 | if _, err := tx.Exec("PRAGMA defer_foreign_keys=ON"); err != nil { 45 | return fmt.Errorf("failed to enable foreign key deferral: %w", err) 46 | } else if err := fn(tx, log); err != nil { 47 | return err 48 | } else if err := foreignKeyCheck(tx, log); err != nil { 49 | return fmt.Errorf("failed foreign key check: %w", err) 50 | } 51 | return setDBVersion(tx, version) 52 | }) 53 | if err != nil { 54 | return fmt.Errorf("migration %d failed: %w", version, err) 55 | } 56 | log.Info("migration complete", zap.Duration("elapsed", time.Since(start))) 57 | } 58 | return nil 59 | } 60 | 61 | func (s *Store) init() error { 62 | // calculate the expected final database version 63 | target := int64(len(migrations) + 1) 64 | 65 | version := getDBVersion(s.db) 66 | switch { 67 | case version == 0: 68 | return s.initNewDatabase(target) 69 | case version < target: 70 | return s.upgradeDatabase(version, target) 71 | case version > target: 72 | return fmt.Errorf("database version %v is newer than expected %v. database downgrades are not supported", version, target) 73 | } 74 | // nothing to do 75 | return nil 76 | } 77 | 78 | func foreignKeyCheck(txn *txn, log *zap.Logger) error { 79 | rows, err := txn.Query("PRAGMA foreign_key_check") 80 | if err != nil { 81 | return fmt.Errorf("failed to run foreign key check: %w", err) 82 | } 83 | defer rows.Close() 84 | var hasErrors bool 85 | for rows.Next() { 86 | var table string 87 | var rowid sql.NullInt64 88 | var fkTable string 89 | var fkRowid sql.NullInt64 90 | 91 | if err := rows.Scan(&table, &rowid, &fkTable, &fkRowid); err != nil { 92 | return fmt.Errorf("failed to scan foreign key check result: %w", err) 93 | } 94 | hasErrors = true 95 | log.Error("foreign key constraint violated", zap.String("table", table), zap.Int64("rowid", rowid.Int64), zap.String("fkTable", fkTable), zap.Int64("fkRowid", fkRowid.Int64)) 96 | } 97 | if err := rows.Err(); err != nil { 98 | return fmt.Errorf("failed to iterate foreign key check results: %w", err) 99 | } else if hasErrors { 100 | return errors.New("foreign key constraint violated") 101 | } 102 | return nil 103 | } 104 | -------------------------------------------------------------------------------- /persist/sqlite/init.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE chain_indices ( 2 | id INTEGER PRIMARY KEY, 3 | block_id BLOB UNIQUE NOT NULL, 4 | height INTEGER UNIQUE NOT NULL 5 | ); 6 | CREATE INDEX chain_indices_height_idx ON chain_indices (block_id, height); 7 | 8 | CREATE TABLE sia_addresses ( 9 | id INTEGER PRIMARY KEY, 10 | sia_address BLOB UNIQUE NOT NULL, 11 | siacoin_balance BLOB NOT NULL, 12 | immature_siacoin_balance BLOB NOT NULL, 13 | siafund_balance INTEGER NOT NULL 14 | ); 15 | 16 | CREATE TABLE siacoin_elements ( 17 | id BLOB PRIMARY KEY, 18 | siacoin_value BLOB NOT NULL, 19 | merkle_proof BLOB NOT NULL, 20 | leaf_index INTEGER UNIQUE NOT NULL, 21 | maturity_height INTEGER NOT NULL, -- stored as int64 for easier querying 22 | address_id INTEGER NOT NULL REFERENCES sia_addresses (id), 23 | matured BOOLEAN NOT NULL, -- tracks whether the value has been added to the address balance 24 | chain_index_id INTEGER NOT NULL REFERENCES chain_indices (id), 25 | spent_index_id INTEGER REFERENCES chain_indices (id), -- soft delete 26 | spent_event_id INTEGER REFERENCES events (id) -- atomic swap tracking 27 | ); 28 | CREATE INDEX siacoin_elements_address_id_idx ON siacoin_elements (address_id); 29 | CREATE INDEX siacoin_elements_maturity_height_matured_idx ON siacoin_elements (maturity_height, matured); 30 | CREATE INDEX siacoin_elements_chain_index_id_idx ON siacoin_elements (chain_index_id); 31 | CREATE INDEX siacoin_elements_spent_index_id_idx ON siacoin_elements (spent_index_id); 32 | CREATE INDEX siacoin_elements_spent_event_id_idx ON siacoin_elements (spent_event_id); 33 | CREATE INDEX siacoin_elements_address_id_spent_index_id_idx ON siacoin_elements(address_id, spent_index_id); 34 | 35 | CREATE TABLE siafund_elements ( 36 | id BLOB PRIMARY KEY, 37 | claim_start BLOB NOT NULL, 38 | merkle_proof BLOB NOT NULL, 39 | leaf_index INTEGER UNIQUE NOT NULL, 40 | siafund_value INTEGER NOT NULL, 41 | address_id INTEGER NOT NULL REFERENCES sia_addresses (id), 42 | chain_index_id INTEGER NOT NULL REFERENCES chain_indices (id), 43 | spent_index_id INTEGER REFERENCES chain_indices (id), -- soft delete 44 | spent_event_id INTEGER REFERENCES events (id) -- atomic swap tracking 45 | ); 46 | CREATE INDEX siafund_elements_address_id_idx ON siafund_elements (address_id); 47 | CREATE INDEX siafund_elements_chain_index_id_idx ON siafund_elements (chain_index_id); 48 | CREATE INDEX siafund_elements_spent_index_id_idx ON siafund_elements (spent_index_id); 49 | CREATE INDEX siafund_elements_spent_event_id_idx ON siafund_elements (spent_event_id); 50 | CREATE INDEX siafund_elements_address_id_spent_index_id_idx ON siafund_elements(address_id, spent_index_id); 51 | 52 | CREATE TABLE state_tree ( 53 | row INTEGER, 54 | column INTEGER, 55 | value BLOB NOT NULL, 56 | PRIMARY KEY (row, column) 57 | ); 58 | 59 | CREATE TABLE events ( 60 | id INTEGER PRIMARY KEY, 61 | chain_index_id INTEGER NOT NULL REFERENCES chain_indices (id), 62 | event_id BLOB UNIQUE NOT NULL, 63 | maturity_height INTEGER NOT NULL, 64 | date_created INTEGER NOT NULL, 65 | event_type TEXT NOT NULL, 66 | event_data BLOB NOT NULL 67 | ); 68 | CREATE INDEX events_chain_index_id_idx ON events (chain_index_id); 69 | CREATE INDEX events_maturity_height_id_idx ON events (maturity_height DESC, id DESC); 70 | 71 | CREATE TABLE event_addresses ( 72 | event_id INTEGER NOT NULL REFERENCES events (id) ON DELETE CASCADE, 73 | address_id INTEGER NOT NULL REFERENCES sia_addresses (id), 74 | event_maturity_height INTEGER NOT NULL, -- flattened from events to improve query performance 75 | PRIMARY KEY (event_id, address_id) 76 | ); 77 | CREATE INDEX event_addresses_event_id_idx ON event_addresses (event_id); 78 | CREATE INDEX event_addresses_address_id_idx ON event_addresses (address_id); 79 | CREATE INDEX event_addresses_event_id_address_id_event_maturity_height_event_id_idx ON event_addresses (address_id, event_maturity_height DESC, event_id DESC); 80 | 81 | CREATE TABLE wallets ( 82 | id INTEGER PRIMARY KEY, 83 | friendly_name TEXT NOT NULL, 84 | description TEXT NOT NULL, 85 | date_created INTEGER NOT NULL, 86 | last_updated INTEGER NOT NULL, 87 | extra_data BLOB 88 | ); 89 | 90 | CREATE TABLE wallet_addresses ( 91 | wallet_id INTEGER NOT NULL REFERENCES wallets (id), 92 | address_id INTEGER NOT NULL REFERENCES sia_addresses (id), 93 | description TEXT NOT NULL, 94 | spend_policy BLOB, 95 | extra_data BLOB, 96 | UNIQUE (wallet_id, address_id) 97 | ); 98 | CREATE INDEX wallet_addresses_wallet_id_idx ON wallet_addresses (wallet_id); 99 | CREATE INDEX wallet_addresses_address_id_idx ON wallet_addresses (address_id); 100 | CREATE INDEX wallet_addresses_wallet_id_address_id_idx ON wallet_addresses (wallet_id, address_id); 101 | 102 | CREATE TABLE syncer_peers ( 103 | peer_address TEXT PRIMARY KEY NOT NULL, 104 | first_seen INTEGER NOT NULL 105 | ); 106 | 107 | CREATE TABLE syncer_bans ( 108 | net_cidr TEXT PRIMARY KEY NOT NULL, 109 | expiration INTEGER NOT NULL, 110 | reason TEXT NOT NULL 111 | ); 112 | CREATE INDEX syncer_bans_expiration_index_idx ON syncer_bans (expiration); 113 | 114 | CREATE TABLE signing_keys ( 115 | public_key BLOB PRIMARY KEY, 116 | private_key BLOB UNIQUE NOT NULL 117 | ); 118 | 119 | CREATE TABLE global_settings ( 120 | id INTEGER PRIMARY KEY NOT NULL DEFAULT 0 CHECK (id = 0), -- enforce a single row 121 | db_version INTEGER NOT NULL, -- used for migrations 122 | index_mode INTEGER, -- the mode of the data store 123 | last_indexed_height INTEGER NOT NULL, -- the height of the last chain index that was processed 124 | last_indexed_id BLOB NOT NULL, -- the block ID of the last chain index that was processed 125 | element_num_leaves INTEGER NOT NULL, -- the number of leaves in the state tree 126 | key_salt BLOB -- the salt used for deriving keys 127 | ); 128 | -------------------------------------------------------------------------------- /persist/sqlite/migrations.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "fmt" 5 | 6 | "go.sia.tech/core/types" 7 | "go.uber.org/zap" 8 | ) 9 | 10 | func migrateVersion8(tx *txn, _ *zap.Logger) error { 11 | _, err := tx.Exec(`CREATE TABLE signing_keys ( 12 | public_key BLOB PRIMARY KEY, 13 | private_key BLOB UNIQUE NOT NULL 14 | ); 15 | ALTER TABLE global_settings ADD COLUMN key_salt BLOB;`) 16 | return err 17 | } 18 | 19 | // migrateVersion7 adds spent_event_id columns to siacoin_elements and 20 | // siafund_elements to track the event that spent the element. 21 | func migrateVersion7(tx *txn, _ *zap.Logger) error { 22 | const query = `ALTER TABLE siacoin_elements ADD COLUMN spent_event_id INTEGER REFERENCES events (id); 23 | CREATE INDEX siacoin_elements_spent_event_id_idx ON siacoin_elements (spent_event_id); 24 | ALTER TABLE siafund_elements ADD COLUMN spent_event_id INTEGER REFERENCES events (id); 25 | CREATE INDEX siafund_elements_spent_event_id_idx ON siafund_elements (spent_event_id);` 26 | _, err := tx.Exec(query) 27 | return err 28 | } 29 | 30 | // migrateVersion6 flattens the maturity height from events into event_addresses 31 | // to improve query performance. 32 | func migrateVersion6(tx *txn, _ *zap.Logger) error { 33 | const query = ` 34 | CREATE TABLE event_addresses_new ( 35 | event_id INTEGER NOT NULL REFERENCES events (id) ON DELETE CASCADE, 36 | address_id INTEGER NOT NULL REFERENCES sia_addresses (id), 37 | event_maturity_height INTEGER NOT NULL, -- flattened from events to improve query performance 38 | PRIMARY KEY (event_id, address_id) 39 | ); 40 | INSERT INTO event_addresses_new (event_id, address_id, event_maturity_height) SELECT ea.event_id, ea.address_id, ev.maturity_height FROM event_addresses ea INNER JOIN events ev ON ea.event_id = ev.id; 41 | 42 | DROP TABLE event_addresses; 43 | 44 | ALTER TABLE event_addresses_new RENAME TO event_addresses; 45 | CREATE INDEX event_addresses_event_id_idx ON event_addresses (event_id); 46 | CREATE INDEX event_addresses_address_id_idx ON event_addresses (address_id); 47 | CREATE INDEX event_addresses_event_id_address_id_event_maturity_height_event_id_idx ON event_addresses (address_id, event_maturity_height DESC, event_id DESC); 48 | ` 49 | _, err := tx.Exec(query) 50 | return err 51 | } 52 | 53 | // migrateVersion5 resets the database to trigger a full resync to switch 54 | // events from JSON to Sia encoding 55 | func migrateVersion5(tx *txn, _ *zap.Logger) error { 56 | if _, err := tx.Exec(`DELETE FROM siacoin_elements;`); err != nil { 57 | return fmt.Errorf("failed to delete siacoin_elements: %w", err) 58 | } else if _, err := tx.Exec(`DELETE FROM siafund_elements;`); err != nil { 59 | return fmt.Errorf("failed to delete siafund_elements: %w", err) 60 | } else if _, err := tx.Exec(`DELETE FROM state_tree;`); err != nil { 61 | return fmt.Errorf("failed to delete state_tree: %w", err) 62 | } else if _, err := tx.Exec(`DELETE FROM event_addresses;`); err != nil { 63 | return fmt.Errorf("failed to delete event_addresses: %w", err) 64 | } else if _, err := tx.Exec(`DELETE FROM events;`); err != nil { 65 | return fmt.Errorf("failed to delete events: %w", err) 66 | } else if _, err := tx.Exec(`DELETE FROM chain_indices;`); err != nil { 67 | return fmt.Errorf("failed to delete chain_indices: %w", err) 68 | } else if _, err := tx.Exec(`DROP TABLE siacoin_elements;`); err != nil { 69 | return fmt.Errorf("failed to drop siacoin_elements: %w", err) 70 | } else if _, err := tx.Exec(`DROP TABLE siafund_elements;`); err != nil { 71 | return fmt.Errorf("failed to drop siafund_elements: %w", err) 72 | } 73 | 74 | _, err := tx.Exec(`UPDATE global_settings SET last_indexed_height=0, last_indexed_id=$1, element_num_leaves=0`, encode(types.ChainIndex{})) 75 | if err != nil { 76 | return fmt.Errorf("failed to reset global_settings: %w", err) 77 | } 78 | 79 | _, err = tx.Exec(`UPDATE sia_addresses SET siacoin_balance=$1, immature_siacoin_balance=$1, siafund_balance=0;`, encode(types.ZeroCurrency)) 80 | if err != nil { 81 | return fmt.Errorf("failed to reset sia_addresses: %w", err) 82 | } 83 | 84 | _, err = tx.Exec(`CREATE TABLE siacoin_elements ( 85 | id BLOB PRIMARY KEY, 86 | siacoin_value BLOB NOT NULL, 87 | merkle_proof BLOB NOT NULL, 88 | leaf_index INTEGER UNIQUE NOT NULL, 89 | maturity_height INTEGER NOT NULL, /* stored as int64 for easier querying */ 90 | address_id INTEGER NOT NULL REFERENCES sia_addresses (id), 91 | matured BOOLEAN NOT NULL, /* tracks whether the value has been added to the address balance */ 92 | chain_index_id INTEGER NOT NULL REFERENCES chain_indices (id), 93 | spent_index_id INTEGER REFERENCES chain_indices (id) /* soft delete */ 94 | ); 95 | CREATE INDEX siacoin_elements_address_id_idx ON siacoin_elements (address_id); 96 | CREATE INDEX siacoin_elements_maturity_height_matured_idx ON siacoin_elements (maturity_height, matured); 97 | CREATE INDEX siacoin_elements_chain_index_id_idx ON siacoin_elements (chain_index_id); 98 | CREATE INDEX siacoin_elements_spent_index_id_idx ON siacoin_elements (spent_index_id); 99 | CREATE INDEX siacoin_elements_address_id_spent_index_id_idx ON siacoin_elements(address_id, spent_index_id);`) 100 | if err != nil { 101 | return fmt.Errorf("failed to create siacoin_elements: %w", err) 102 | } 103 | 104 | _, err = tx.Exec(`CREATE TABLE siafund_elements ( 105 | id BLOB PRIMARY KEY, 106 | claim_start BLOB NOT NULL, 107 | merkle_proof BLOB NOT NULL, 108 | leaf_index INTEGER UNIQUE NOT NULL, 109 | siafund_value INTEGER NOT NULL, 110 | address_id INTEGER NOT NULL REFERENCES sia_addresses (id), 111 | chain_index_id INTEGER NOT NULL REFERENCES chain_indices (id), 112 | spent_index_id INTEGER REFERENCES chain_indices (id) /* soft delete */ 113 | ); 114 | CREATE INDEX siafund_elements_address_id_idx ON siafund_elements (address_id); 115 | CREATE INDEX siafund_elements_chain_index_id_idx ON siafund_elements (chain_index_id); 116 | CREATE INDEX siafund_elements_spent_index_id_idx ON siafund_elements (spent_index_id); 117 | CREATE INDEX siafund_elements_address_id_spent_index_id_idx ON siafund_elements(address_id, spent_index_id);`) 118 | if err != nil { 119 | return fmt.Errorf("failed to create siafund_elements: %w", err) 120 | } 121 | return nil 122 | } 123 | 124 | // migrateVersion4 splits the height and ID of the last indexed tip into two 125 | // separate columns for easier querying. 126 | func migrateVersion4(tx *txn, _ *zap.Logger) error { 127 | var dbVersion int 128 | var indexMode int 129 | var elementNumLeaves uint64 130 | var index types.ChainIndex 131 | err := tx.QueryRow(`SELECT db_version, index_mode, element_num_leaves, last_indexed_tip FROM global_settings`).Scan(&dbVersion, &indexMode, &elementNumLeaves, decode(&index)) 132 | if err != nil { 133 | return fmt.Errorf("failed to get last indexed tip: %w", err) 134 | } else if _, err := tx.Exec(`DROP TABLE global_settings`); err != nil { 135 | return fmt.Errorf("failed to drop global_settings: %w", err) 136 | } 137 | 138 | _, err = tx.Exec(`CREATE TABLE global_settings ( 139 | id INTEGER PRIMARY KEY NOT NULL DEFAULT 0 CHECK (id = 0), -- enforce a single row 140 | db_version INTEGER NOT NULL, -- used for migrations 141 | index_mode INTEGER, -- the mode of the data store 142 | last_indexed_height INTEGER NOT NULL, -- the height of the last chain index that was processed 143 | last_indexed_id BLOB NOT NULL, -- the block ID of the last chain index that was processed 144 | element_num_leaves INTEGER NOT NULL -- the number of leaves in the state tree 145 | );`) 146 | if err != nil { 147 | return fmt.Errorf("failed to create global_settings: %w", err) 148 | } 149 | 150 | _, err = tx.Exec(`INSERT INTO global_settings (id, db_version, index_mode, last_indexed_height, last_indexed_id, element_num_leaves) VALUES (0, ?, ?, ?, ?, ?)`, dbVersion, indexMode, index.Height, encode(index.ID), elementNumLeaves) 151 | return err 152 | } 153 | 154 | // migrateVersion3 adds additional indices to event_addresses and wallet_addresses 155 | // to improve query performance. 156 | func migrateVersion3(tx *txn, _ *zap.Logger) error { 157 | _, err := tx.Exec(`CREATE INDEX event_addresses_event_id_address_id_idx ON event_addresses (event_id, address_id); 158 | CREATE INDEX wallet_addresses_wallet_id_address_id_idx ON wallet_addresses (wallet_id, address_id);`) 159 | return err 160 | } 161 | 162 | // migrateVersion2 recreates indices and speeds up event queries 163 | func migrateVersion2(tx *txn, _ *zap.Logger) error { 164 | _, err := tx.Exec(`DROP INDEX IF EXISTS chain_indices_height; 165 | DROP INDEX IF EXISTS siacoin_elements_address_id; 166 | DROP INDEX IF EXISTS siacoin_elements_maturity_height_matured; 167 | DROP INDEX IF EXISTS siacoin_elements_chain_index_id; 168 | DROP INDEX IF EXISTS siacoin_elements_spent_index_id; 169 | DROP INDEX IF EXISTS siacoin_elements_address_id_spent_index_id; 170 | DROP INDEX IF EXISTS siafund_elements_address_id; 171 | DROP INDEX IF EXISTS siafund_elements_chain_index_id; 172 | DROP INDEX IF EXISTS siafund_elements_spent_index_id; 173 | DROP INDEX IF EXISTS siafund_elements_address_id_spent_index_id; 174 | DROP INDEX IF EXISTS events_chain_index_id; 175 | DROP INDEX IF EXISTS event_addresses_event_id_idx; 176 | DROP INDEX IF EXISTS event_addresses_address_id_idx; 177 | DROP INDEX IF EXISTS wallet_addresses_wallet_id; 178 | DROP INDEX IF EXISTS wallet_addresses_address_id; 179 | DROP INDEX IF EXISTS syncer_bans_expiration_index; 180 | 181 | CREATE INDEX IF NOT EXISTS chain_indices_height_idx ON chain_indices (block_id, height); 182 | CREATE INDEX IF NOT EXISTS siacoin_elements_address_id_idx ON siacoin_elements (address_id); 183 | CREATE INDEX IF NOT EXISTS siacoin_elements_maturity_height_matured_idx ON siacoin_elements (maturity_height, matured); 184 | CREATE INDEX IF NOT EXISTS siacoin_elements_chain_index_id_idx ON siacoin_elements (chain_index_id); 185 | CREATE INDEX IF NOT EXISTS siacoin_elements_spent_index_id_idx ON siacoin_elements (spent_index_id); 186 | CREATE INDEX IF NOT EXISTS siacoin_elements_address_id_spent_index_id_idx ON siacoin_elements(address_id, spent_index_id); 187 | CREATE INDEX IF NOT EXISTS siafund_elements_address_id_idx ON siafund_elements (address_id); 188 | CREATE INDEX IF NOT EXISTS siafund_elements_chain_index_id_idx ON siafund_elements (chain_index_id); 189 | CREATE INDEX IF NOT EXISTS siafund_elements_spent_index_id_idx ON siafund_elements (spent_index_id); 190 | CREATE INDEX IF NOT EXISTS siafund_elements_address_id_spent_index_id_idx ON siafund_elements(address_id, spent_index_id); 191 | CREATE INDEX IF NOT EXISTS events_chain_index_id_idx ON events (chain_index_id); 192 | CREATE INDEX IF NOT EXISTS events_maturity_height_id_idx ON events (maturity_height DESC, id DESC); 193 | CREATE INDEX IF NOT EXISTS event_addresses_event_id_idx ON event_addresses (event_id); 194 | CREATE INDEX IF NOT EXISTS event_addresses_address_id_idx ON event_addresses (address_id); 195 | CREATE INDEX IF NOT EXISTS wallet_addresses_wallet_id_idx ON wallet_addresses (wallet_id); 196 | CREATE INDEX IF NOT EXISTS wallet_addresses_address_id_idx ON wallet_addresses (address_id); 197 | CREATE INDEX IF NOT EXISTS syncer_bans_expiration_index_idx ON syncer_bans (expiration);`) 198 | return err 199 | } 200 | 201 | // migrations is a list of functions that are run to migrate the database from 202 | // one version to the next. Migrations are used to update existing databases to 203 | // match the schema in init.sql. 204 | var migrations = []func(tx *txn, log *zap.Logger) error{ 205 | migrateVersion2, 206 | migrateVersion3, 207 | migrateVersion4, 208 | migrateVersion5, 209 | migrateVersion6, 210 | migrateVersion7, 211 | migrateVersion8, 212 | } 213 | -------------------------------------------------------------------------------- /persist/sqlite/migrations_test.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "path/filepath" 7 | "testing" 8 | 9 | "go.sia.tech/core/types" 10 | "go.uber.org/zap/zaptest" 11 | ) 12 | 13 | // nolint:misspell 14 | const initialSchema = `CREATE TABLE chain_indices ( 15 | id INTEGER PRIMARY KEY, 16 | block_id BLOB UNIQUE NOT NULL, 17 | height INTEGER UNIQUE NOT NULL 18 | ); 19 | CREATE INDEX chain_indices_height ON chain_indices (block_id, height); 20 | 21 | CREATE TABLE sia_addresses ( 22 | id INTEGER PRIMARY KEY, 23 | sia_address BLOB UNIQUE NOT NULL, 24 | siacoin_balance BLOB NOT NULL, 25 | immature_siacoin_balance BLOB NOT NULL, 26 | siafund_balance INTEGER NOT NULL 27 | ); 28 | 29 | CREATE TABLE siacoin_elements ( 30 | id BLOB PRIMARY KEY, 31 | siacoin_value BLOB NOT NULL, 32 | merkle_proof BLOB NOT NULL, 33 | leaf_index INTEGER NOT NULL, 34 | maturity_height INTEGER NOT NULL, /* stored as int64 for easier querying */ 35 | address_id INTEGER NOT NULL REFERENCES sia_addresses (id), 36 | matured BOOLEAN NOT NULL, /* tracks whether the value has been added to the address balance */ 37 | chain_index_id INTEGER NOT NULL REFERENCES chain_indices (id), 38 | spent_index_id INTEGER REFERENCES chain_indices (id) /* soft delete */ 39 | ); 40 | CREATE INDEX siacoin_elements_address_id ON siacoin_elements (address_id); 41 | CREATE INDEX siacoin_elements_maturity_height_matured ON siacoin_elements (maturity_height, matured); 42 | CREATE INDEX siacoin_elements_chain_index_id ON siacoin_elements (chain_index_id); 43 | CREATE INDEX siacoin_elements_spent_index_id ON siacoin_elements (spent_index_id); 44 | CREATE INDEX siacoin_elements_address_id_spent_index_id ON siacoin_elements(address_id, spent_index_id); 45 | 46 | CREATE TABLE siafund_elements ( 47 | id BLOB PRIMARY KEY, 48 | claim_start BLOB NOT NULL, 49 | merkle_proof BLOB NOT NULL, 50 | leaf_index INTEGER NOT NULL, 51 | siafund_value INTEGER NOT NULL, 52 | address_id INTEGER NOT NULL REFERENCES sia_addresses (id), 53 | chain_index_id INTEGER NOT NULL REFERENCES chain_indices (id), 54 | spent_index_id INTEGER REFERENCES chain_indices (id) /* soft delete */ 55 | ); 56 | CREATE INDEX siafund_elements_address_id ON siafund_elements (address_id); 57 | CREATE INDEX siafund_elements_chain_index_id ON siafund_elements (chain_index_id); 58 | CREATE INDEX siafund_elements_spent_index_id ON siafund_elements (spent_index_id); 59 | CREATE INDEX siafund_elements_address_id_spent_index_id ON siafund_elements(address_id, spent_index_id); 60 | 61 | CREATE TABLE state_tree ( 62 | row INTEGER, 63 | column INTEGER, 64 | value BLOB NOT NULL, 65 | PRIMARY KEY (row, column) 66 | ); 67 | 68 | CREATE TABLE events ( 69 | id INTEGER PRIMARY KEY, 70 | chain_index_id INTEGER NOT NULL REFERENCES chain_indices (id), 71 | event_id BLOB UNIQUE NOT NULL, 72 | maturity_height INTEGER NOT NULL, 73 | date_created INTEGER NOT NULL, 74 | event_type TEXT NOT NULL, 75 | event_data BLOB NOT NULL 76 | ); 77 | CREATE INDEX events_chain_index_id ON events (chain_index_id); 78 | 79 | CREATE TABLE event_addresses ( 80 | event_id INTEGER NOT NULL REFERENCES events (id) ON DELETE CASCADE, 81 | address_id INTEGER NOT NULL REFERENCES sia_addresses (id), 82 | PRIMARY KEY (event_id, address_id) 83 | ); 84 | CREATE INDEX event_addresses_event_id_idx ON event_addresses (event_id); 85 | CREATE INDEX event_addresses_address_id_idx ON event_addresses (address_id); 86 | 87 | CREATE TABLE wallets ( 88 | id INTEGER PRIMARY KEY, 89 | friendly_name TEXT NOT NULL, 90 | description TEXT NOT NULL, 91 | date_created INTEGER NOT NULL, 92 | last_updated INTEGER NOT NULL, 93 | extra_data BLOB 94 | ); 95 | 96 | CREATE TABLE wallet_addresses ( 97 | wallet_id INTEGER NOT NULL REFERENCES wallets (id), 98 | address_id INTEGER NOT NULL REFERENCES sia_addresses (id), 99 | description TEXT NOT NULL, 100 | spend_policy BLOB, 101 | extra_data BLOB, 102 | UNIQUE (wallet_id, address_id) 103 | ); 104 | CREATE INDEX wallet_addresses_wallet_id ON wallet_addresses (wallet_id); 105 | CREATE INDEX wallet_addresses_address_id ON wallet_addresses (address_id); 106 | 107 | CREATE TABLE syncer_peers ( 108 | peer_address TEXT PRIMARY KEY NOT NULL, 109 | first_seen INTEGER NOT NULL 110 | ); 111 | 112 | CREATE TABLE syncer_bans ( 113 | net_cidr TEXT PRIMARY KEY NOT NULL, 114 | expiration INTEGER NOT NULL, 115 | reason TEXT NOT NULL 116 | ); 117 | CREATE INDEX syncer_bans_expiration_index ON syncer_bans (expiration); 118 | 119 | CREATE TABLE global_settings ( 120 | id INTEGER PRIMARY KEY NOT NULL DEFAULT 0 CHECK (id = 0), -- enforce a single row 121 | db_version INTEGER NOT NULL, -- used for migrations 122 | index_mode INTEGER, -- the mode of the data store 123 | last_indexed_tip BLOB NOT NULL, -- the last chain index that was processed 124 | element_num_leaves INTEGER NOT NULL -- the number of leaves in the state tree 125 | );` 126 | 127 | func TestMigrationConsistency(t *testing.T) { 128 | fp := filepath.Join(t.TempDir(), "walletd.sqlite3") 129 | db, err := sql.Open("sqlite3", sqliteFilepath(fp)) 130 | if err != nil { 131 | t.Fatal(err) 132 | } 133 | defer db.Close() 134 | 135 | if _, err := db.Exec(initialSchema); err != nil { 136 | t.Fatal(err) 137 | } 138 | 139 | // initialize the settings table 140 | _, err = db.Exec(`INSERT INTO global_settings (id, db_version, index_mode, element_num_leaves, last_indexed_tip) VALUES (0, 1, 0, 0, ?)`, encode(types.ChainIndex{})) 141 | if err != nil { 142 | t.Fatal(err) 143 | } 144 | 145 | if err := db.Close(); err != nil { 146 | t.Fatal(err) 147 | } 148 | 149 | expectedVersion := int64(len(migrations) + 1) 150 | log := zaptest.NewLogger(t) 151 | store, err := OpenDatabase(fp, WithLog(log)) 152 | if err != nil { 153 | t.Fatal(err) 154 | } 155 | defer store.Close() 156 | v := getDBVersion(store.db) 157 | if v != expectedVersion { 158 | t.Fatalf("expected version %d, got %d", expectedVersion, v) 159 | } else if err := store.Close(); err != nil { 160 | t.Fatal(err) 161 | } 162 | 163 | // ensure the database does not change version when opened again 164 | store, err = OpenDatabase(fp, WithLog(log)) 165 | if err != nil { 166 | t.Fatal(err) 167 | } 168 | defer store.Close() 169 | v = getDBVersion(store.db) 170 | if v != expectedVersion { 171 | t.Fatalf("expected version %d, got %d", expectedVersion, v) 172 | } 173 | 174 | fp2 := filepath.Join(t.TempDir(), "walletd.sqlite3") 175 | baseline, err := OpenDatabase(fp2) 176 | if err != nil { 177 | t.Fatal(err) 178 | } 179 | defer baseline.Close() 180 | 181 | getTableIndices := func(db *sql.DB) (map[string]bool, error) { 182 | const query = `SELECT name, tbl_name, sql FROM sqlite_schema WHERE type='index'` 183 | rows, err := db.Query(query) 184 | if err != nil { 185 | return nil, err 186 | } 187 | defer rows.Close() 188 | 189 | indices := make(map[string]bool) 190 | for rows.Next() { 191 | var name, table string 192 | var sqlStr sql.NullString // auto indices have no sql 193 | if err := rows.Scan(&name, &table, &sqlStr); err != nil { 194 | return nil, err 195 | } 196 | indices[fmt.Sprintf("%s.%s.%s", name, table, sqlStr.String)] = true 197 | } 198 | if err := rows.Err(); err != nil { 199 | return nil, err 200 | } 201 | return indices, nil 202 | } 203 | 204 | // ensure the migrated database has the same indices as the baseline 205 | baselineIndices, err := getTableIndices(baseline.db) 206 | if err != nil { 207 | t.Fatal(err) 208 | } 209 | 210 | migratedIndices, err := getTableIndices(store.db) 211 | if err != nil { 212 | t.Fatal(err) 213 | } 214 | 215 | for k := range baselineIndices { 216 | if !migratedIndices[k] { 217 | t.Errorf("missing index %s", k) 218 | } 219 | } 220 | 221 | for k := range migratedIndices { 222 | if !baselineIndices[k] { 223 | t.Errorf("unexpected index %s", k) 224 | } 225 | } 226 | 227 | getTables := func(db *sql.DB) (map[string]bool, error) { 228 | const query = `SELECT name FROM sqlite_schema WHERE type='table'` 229 | rows, err := db.Query(query) 230 | if err != nil { 231 | return nil, err 232 | } 233 | defer rows.Close() 234 | 235 | tables := make(map[string]bool) 236 | for rows.Next() { 237 | var name string 238 | if err := rows.Scan(&name); err != nil { 239 | return nil, err 240 | } 241 | tables[name] = true 242 | } 243 | if err := rows.Err(); err != nil { 244 | return nil, err 245 | } 246 | return tables, nil 247 | } 248 | 249 | // ensure the migrated database has the same tables as the baseline 250 | baselineTables, err := getTables(baseline.db) 251 | if err != nil { 252 | t.Fatal(err) 253 | } 254 | 255 | migratedTables, err := getTables(store.db) 256 | if err != nil { 257 | t.Fatal(err) 258 | } 259 | 260 | for k := range baselineTables { 261 | if !migratedTables[k] { 262 | t.Errorf("missing table %s", k) 263 | } 264 | } 265 | for k := range migratedTables { 266 | if !baselineTables[k] { 267 | t.Errorf("unexpected table %s", k) 268 | } 269 | } 270 | 271 | // ensure each table has the same columns as the baseline 272 | getTableColumns := func(db *sql.DB, table string) (map[string]bool, error) { 273 | query := fmt.Sprintf(`PRAGMA table_info(%s)`, table) // cannot use parameterized query for PRAGMA statements 274 | rows, err := db.Query(query) 275 | if err != nil { 276 | return nil, err 277 | } 278 | defer rows.Close() 279 | 280 | columns := make(map[string]bool) 281 | for rows.Next() { 282 | var cid int 283 | var name, colType string 284 | var defaultValue sql.NullString 285 | var notNull bool 286 | var primaryKey int // composite keys are indices 287 | if err := rows.Scan(&cid, &name, &colType, ¬Null, &defaultValue, &primaryKey); err != nil { 288 | return nil, err 289 | } 290 | // column ID is ignored since it may not match between the baseline and migrated databases 291 | key := fmt.Sprintf("%s.%s.%s.%t.%d", name, colType, defaultValue.String, notNull, primaryKey) 292 | columns[key] = true 293 | } 294 | if err := rows.Err(); err != nil { 295 | return nil, err 296 | } 297 | return columns, nil 298 | } 299 | 300 | for k := range baselineTables { 301 | baselineColumns, err := getTableColumns(baseline.db, k) 302 | if err != nil { 303 | t.Fatal(err) 304 | } 305 | migratedColumns, err := getTableColumns(store.db, k) 306 | if err != nil { 307 | t.Fatal(err) 308 | } 309 | 310 | for c := range baselineColumns { 311 | if !migratedColumns[c] { 312 | t.Errorf("missing column %s.%s", k, c) 313 | } 314 | } 315 | 316 | for c := range migratedColumns { 317 | if !baselineColumns[c] { 318 | t.Errorf("unexpected column %s.%s", k, c) 319 | } 320 | } 321 | } 322 | } 323 | -------------------------------------------------------------------------------- /persist/sqlite/options.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import "go.uber.org/zap" 4 | 5 | // An Option is a function that configures the Store. 6 | type Option func(*Store) 7 | 8 | // WithLog sets the logger for the store. 9 | func WithLog(log *zap.Logger) Option { 10 | return func(s *Store) { 11 | s.log = log 12 | } 13 | } 14 | 15 | // WithRetainSpentElements sets the number of blocks to retain 16 | // spent elements. 17 | func WithRetainSpentElements(blocks uint64) Option { 18 | return func(s *Store) { 19 | s.spentElementRetentionBlocks = blocks 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /persist/sqlite/peers.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "fmt" 7 | "net" 8 | "strconv" 9 | "strings" 10 | "sync" 11 | "time" 12 | 13 | "go.sia.tech/coreutils/syncer" 14 | "go.uber.org/zap" 15 | ) 16 | 17 | // A PeerStore stores information about peers. 18 | type PeerStore struct { 19 | s *Store 20 | 21 | // session-specific peer info is stored in memory to reduce write load 22 | // on the database 23 | mu sync.Mutex 24 | peerInfo map[string]syncer.PeerInfo 25 | } 26 | 27 | // AddPeer adds the given peer to the store. 28 | func (ps *PeerStore) AddPeer(peer string) error { 29 | ps.mu.Lock() 30 | defer ps.mu.Unlock() 31 | ps.peerInfo[peer] = syncer.PeerInfo{ 32 | Address: peer, 33 | FirstSeen: time.Now(), 34 | } 35 | return ps.s.AddPeer(peer) 36 | } 37 | 38 | // Peers returns the addresses of all known peers. 39 | func (ps *PeerStore) Peers() ([]syncer.PeerInfo, error) { 40 | ps.mu.Lock() 41 | defer ps.mu.Unlock() 42 | 43 | // copy the map to a slice 44 | peers := make([]syncer.PeerInfo, 0, len(ps.peerInfo)) 45 | for _, pi := range ps.peerInfo { 46 | peers = append(peers, pi) 47 | } 48 | return peers, nil 49 | } 50 | 51 | // UpdatePeerInfo updates the information for the given peer. 52 | func (ps *PeerStore) UpdatePeerInfo(peer string, fn func(*syncer.PeerInfo)) error { 53 | ps.mu.Lock() 54 | defer ps.mu.Unlock() 55 | if pi, ok := ps.peerInfo[peer]; !ok { 56 | return syncer.ErrPeerNotFound 57 | } else { 58 | fn(&pi) 59 | ps.peerInfo[peer] = pi 60 | } 61 | return nil 62 | } 63 | 64 | // Ban temporarily bans the given peer. 65 | func (ps *PeerStore) Ban(peer string, duration time.Duration, reason string) error { 66 | return ps.s.Ban(peer, duration, reason) 67 | } 68 | 69 | // Banned returns true if the peer is banned. 70 | func (ps *PeerStore) Banned(peer string) (bool, error) { 71 | return ps.s.Banned(peer) 72 | } 73 | 74 | // PeerInfo returns the information for the given peer. 75 | func (ps *PeerStore) PeerInfo(peer string) (syncer.PeerInfo, error) { 76 | ps.mu.Lock() 77 | defer ps.mu.Unlock() 78 | if pi, ok := ps.peerInfo[peer]; ok { 79 | return pi, nil 80 | } 81 | return syncer.PeerInfo{}, syncer.ErrPeerNotFound 82 | } 83 | 84 | // NewPeerStore creates a new peer store using the given store. 85 | func NewPeerStore(s *Store) (syncer.PeerStore, error) { 86 | ps := &PeerStore{s: s, peerInfo: make(map[string]syncer.PeerInfo)} 87 | peers, err := s.Peers() 88 | if err != nil { 89 | return nil, fmt.Errorf("failed to load peers: %w", err) 90 | } 91 | for _, pi := range peers { 92 | ps.peerInfo[pi.Address] = pi 93 | } 94 | return ps, nil 95 | } 96 | 97 | func scanPeerInfo(s scanner) (pi syncer.PeerInfo, err error) { 98 | err = s.Scan(&pi.Address, decode(&pi.FirstSeen)) 99 | return 100 | } 101 | 102 | // AddPeer adds the given peer to the store. 103 | func (s *Store) AddPeer(peer string) error { 104 | return s.transaction(func(tx *txn) error { 105 | const query = `INSERT INTO syncer_peers (peer_address, first_seen) VALUES ($1, $2) ON CONFLICT (peer_address) DO NOTHING` 106 | _, err := tx.Exec(query, peer, encode(time.Now())) 107 | return err 108 | }) 109 | } 110 | 111 | // Peers returns the addresses of all known peers. 112 | func (s *Store) Peers() (peers []syncer.PeerInfo, _ error) { 113 | err := s.transaction(func(tx *txn) error { 114 | const query = `SELECT peer_address, first_seen FROM syncer_peers` 115 | rows, err := tx.Query(query) 116 | if err != nil { 117 | return err 118 | } 119 | defer rows.Close() 120 | for rows.Next() { 121 | peer, err := scanPeerInfo(rows) 122 | if err != nil { 123 | return fmt.Errorf("failed to scan peer info: %w", err) 124 | } 125 | peers = append(peers, peer) 126 | } 127 | return rows.Err() 128 | }) 129 | return peers, err 130 | } 131 | 132 | // normalizePeer normalizes a peer address to a CIDR subnet. 133 | func normalizePeer(peer string) (string, error) { 134 | host, _, err := net.SplitHostPort(peer) 135 | if err != nil { 136 | host = peer 137 | } 138 | if strings.IndexByte(host, '/') != -1 { 139 | _, subnet, err := net.ParseCIDR(host) 140 | if err != nil { 141 | return "", fmt.Errorf("failed to parse CIDR: %w", err) 142 | } 143 | return subnet.String(), nil 144 | } 145 | 146 | ip := net.ParseIP(host) 147 | if ip == nil { 148 | return "", errors.New("invalid IP address") 149 | } 150 | 151 | var maskLen int 152 | if ip.To4() != nil { 153 | maskLen = 32 154 | } else { 155 | maskLen = 128 156 | } 157 | 158 | _, normalized, err := net.ParseCIDR(fmt.Sprintf("%s/%d", ip.String(), maskLen)) 159 | if err != nil { 160 | panic("failed to parse CIDR") 161 | } 162 | return normalized.String(), nil 163 | } 164 | 165 | // Ban temporarily bans one or more IPs. The addr should either be a single 166 | // IP with port (e.g. 1.2.3.4:5678) or a CIDR subnet (e.g. 1.2.3.4/16). 167 | func (s *Store) Ban(peer string, duration time.Duration, reason string) error { 168 | address, err := normalizePeer(peer) 169 | if err != nil { 170 | return err 171 | } 172 | return s.transaction(func(tx *txn) error { 173 | const query = `INSERT INTO syncer_bans (net_cidr, expiration, reason) VALUES ($1, $2, $3) ON CONFLICT (net_cidr) DO UPDATE SET expiration=EXCLUDED.expiration, reason=EXCLUDED.reason` 174 | _, err := tx.Exec(query, address, encode(time.Now().Add(duration)), reason) 175 | return err 176 | }) 177 | } 178 | 179 | // Banned returns true if the peer is banned. 180 | func (s *Store) Banned(peer string) (banned bool, _ error) { 181 | // normalize the peer into a CIDR subnet 182 | peer, err := normalizePeer(peer) 183 | if err != nil { 184 | return false, fmt.Errorf("failed to normalize peer: %w", err) 185 | } 186 | 187 | _, subnet, err := net.ParseCIDR(peer) 188 | if err != nil { 189 | return false, fmt.Errorf("failed to parse CIDR: %w", err) 190 | } 191 | 192 | // check all subnets from the given subnet to the max subnet length 193 | var maxMaskLen int 194 | if subnet.IP.To4() != nil { 195 | maxMaskLen = 32 196 | } else { 197 | maxMaskLen = 128 198 | } 199 | 200 | checkSubnets := make([]string, 0, maxMaskLen) 201 | for i := maxMaskLen; i > 0; i-- { 202 | _, subnet, err := net.ParseCIDR(subnet.IP.String() + "/" + strconv.Itoa(i)) 203 | if err != nil { 204 | panic("failed to parse CIDR") 205 | } 206 | checkSubnets = append(checkSubnets, subnet.String()) 207 | } 208 | 209 | err = s.transaction(func(tx *txn) error { 210 | checkSubnetStmt, err := tx.Prepare(`SELECT expiration FROM syncer_bans WHERE net_cidr = $1 ORDER BY expiration DESC LIMIT 1`) 211 | if err != nil { 212 | return fmt.Errorf("failed to prepare statement: %w", err) 213 | } 214 | defer checkSubnetStmt.Close() 215 | 216 | for _, subnet := range checkSubnets { 217 | var expiration time.Time 218 | 219 | err := checkSubnetStmt.QueryRow(subnet).Scan(decode(&expiration)) 220 | banned = time.Now().Before(expiration) // will return false for any sql errors, including ErrNoRows 221 | if err != nil && !errors.Is(err, sql.ErrNoRows) { 222 | return fmt.Errorf("failed to check ban status: %w", err) 223 | } else if banned { 224 | s.log.Debug("found ban", zap.String("subnet", subnet), zap.Time("expiration", expiration)) 225 | return nil 226 | } 227 | } 228 | return nil 229 | }) 230 | if err != nil && !errors.Is(err, sql.ErrNoRows) { 231 | return false, fmt.Errorf("failed to check ban status: %w", err) 232 | } 233 | return banned, nil 234 | } 235 | -------------------------------------------------------------------------------- /persist/sqlite/peers_test.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "net" 5 | "path/filepath" 6 | "testing" 7 | "time" 8 | 9 | "go.sia.tech/coreutils/syncer" 10 | "go.uber.org/zap/zaptest" 11 | ) 12 | 13 | func TestAddPeer(t *testing.T) { 14 | log := zaptest.NewLogger(t) 15 | db, err := OpenDatabase(filepath.Join(t.TempDir(), "test.db"), WithLog(log.Named("sqlite3"))) 16 | if err != nil { 17 | t.Fatal(err) 18 | } 19 | defer db.Close() 20 | 21 | ps, err := NewPeerStore(db) 22 | if err != nil { 23 | t.Fatal(err) 24 | } 25 | 26 | const peer = "1.2.3.4:9981" 27 | 28 | if err := ps.AddPeer(peer); err != nil { 29 | t.Fatal(err) 30 | } 31 | 32 | lastConnect := time.Now().UTC().Truncate(time.Second) // stored as unix milliseconds 33 | syncedBlocks := uint64(15) 34 | syncDuration := 5 * time.Second 35 | 36 | err = ps.UpdatePeerInfo(peer, func(info *syncer.PeerInfo) { 37 | info.LastConnect = lastConnect 38 | info.SyncedBlocks = syncedBlocks 39 | info.SyncDuration = syncDuration 40 | }) 41 | if err != nil { 42 | t.Fatal(err) 43 | } 44 | 45 | info, err := ps.PeerInfo(peer) 46 | if err != nil { 47 | t.Fatal(err) 48 | } 49 | 50 | if !info.LastConnect.Equal(lastConnect) { 51 | t.Errorf("expected LastConnect = %v; got %v", lastConnect, info.LastConnect) 52 | } 53 | if info.SyncedBlocks != syncedBlocks { 54 | t.Errorf("expected SyncedBlocks = %d; got %d", syncedBlocks, info.SyncedBlocks) 55 | } 56 | if info.SyncDuration != 5*time.Second { 57 | t.Errorf("expected SyncDuration = %s; got %s", syncDuration, info.SyncDuration) 58 | } 59 | 60 | peers, err := ps.Peers() 61 | if err != nil { 62 | t.Fatal(err) 63 | } else if len(peers) != 1 { 64 | t.Fatalf("expected 1 peer; got %d", len(peers)) 65 | } else if peerInfo := peers[0]; peerInfo.Address != peer { 66 | t.Errorf("expected peer address = %q; got %q", peer, peerInfo.Address) 67 | } else if peerInfo.LastConnect != lastConnect { 68 | t.Errorf("expected LastConnect = %v; got %v", lastConnect, peerInfo.LastConnect) 69 | } else if peerInfo.SyncedBlocks != syncedBlocks { 70 | t.Errorf("expected SyncedBlocks = %d; got %d", syncedBlocks, peerInfo.SyncedBlocks) 71 | } else if peerInfo.SyncDuration != syncDuration { 72 | t.Errorf("expected SyncDuration = %s; got %s", syncDuration, peerInfo.SyncDuration) 73 | } else if peerInfo.FirstSeen.IsZero() { 74 | t.Errorf("expected FirstSeen to be non-zero; got %v", peerInfo.FirstSeen) 75 | } 76 | } 77 | 78 | func TestBanPeer(t *testing.T) { 79 | log := zaptest.NewLogger(t) 80 | db, err := OpenDatabase(filepath.Join(t.TempDir(), "test.db"), WithLog(log.Named("sqlite3"))) 81 | if err != nil { 82 | t.Fatal(err) 83 | } 84 | defer db.Close() 85 | 86 | ps, err := NewPeerStore(db) 87 | if err != nil { 88 | t.Fatal(err) 89 | } 90 | 91 | const peer = "1.2.3.4" 92 | 93 | if banned, err := ps.Banned(peer); err != nil || banned { 94 | t.Fatal("expected peer to not be banned", err) 95 | } 96 | 97 | // ban the peer 98 | ps.Ban(peer, 5*time.Second, "test") 99 | 100 | if banned, err := ps.Banned(peer); err != nil || !banned { 101 | t.Fatal("expected peer to be banned", err) 102 | } 103 | 104 | // wait for the ban to expire 105 | time.Sleep(5 * time.Second) 106 | 107 | if banned, err := ps.Banned(peer); err != nil || banned { 108 | t.Fatal("expected peer to not be banned", err) 109 | } 110 | 111 | // ban a subnet 112 | _, subnet, err := net.ParseCIDR(peer + "/24") 113 | if err != nil { 114 | t.Fatal(err) 115 | } 116 | 117 | t.Log("banning", subnet) 118 | ps.Ban(subnet.String(), time.Second, "test") 119 | if banned, err := ps.Banned(peer); err != nil || !banned { 120 | t.Fatal("expected peer to be banned", err) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /persist/sqlite/sql.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "strings" 7 | "time" 8 | 9 | _ "github.com/mattn/go-sqlite3" // import sqlite3 driver 10 | "go.uber.org/zap" 11 | ) 12 | 13 | const ( 14 | longQueryDuration = 10 * time.Millisecond 15 | longTxnDuration = time.Second // reduce syncing spam 16 | ) 17 | 18 | type ( 19 | // A scanner is an interface that wraps the Scan method of sql.Rows and sql.Row 20 | scanner interface { 21 | Scan(dest ...any) error 22 | } 23 | 24 | // A stmt wraps a *sql.Stmt, logging slow queries. 25 | stmt struct { 26 | *sql.Stmt 27 | query string 28 | 29 | log *zap.Logger 30 | } 31 | 32 | // A txn wraps a *sql.Tx, logging slow queries. 33 | txn struct { 34 | *sql.Tx 35 | log *zap.Logger 36 | } 37 | 38 | // A row wraps a *sql.Row, logging slow queries. 39 | row struct { 40 | *sql.Row 41 | log *zap.Logger 42 | } 43 | 44 | // rows wraps a *sql.Rows, logging slow queries. 45 | rows struct { 46 | *sql.Rows 47 | 48 | log *zap.Logger 49 | } 50 | ) 51 | 52 | func (r *rows) Next() bool { 53 | start := time.Now() 54 | next := r.Rows.Next() 55 | if dur := time.Since(start); dur > longQueryDuration { 56 | r.log.Debug("slow next", zap.Duration("elapsed", dur), zap.Stack("stack")) 57 | } 58 | return next 59 | } 60 | 61 | func (r *rows) Scan(dest ...any) error { 62 | start := time.Now() 63 | err := r.Rows.Scan(dest...) 64 | if dur := time.Since(start); dur > longQueryDuration { 65 | r.log.Debug("slow scan", zap.Duration("elapsed", dur), zap.Stack("stack")) 66 | } 67 | return err 68 | } 69 | 70 | func (r *row) Scan(dest ...any) error { 71 | start := time.Now() 72 | err := r.Row.Scan(dest...) 73 | if dur := time.Since(start); dur > longQueryDuration { 74 | r.log.Debug("slow scan", zap.Duration("elapsed", dur), zap.Stack("stack")) 75 | } 76 | return err 77 | } 78 | 79 | func (s *stmt) Exec(args ...any) (sql.Result, error) { 80 | return s.ExecContext(context.Background(), args...) 81 | } 82 | 83 | func (s *stmt) ExecContext(ctx context.Context, args ...any) (sql.Result, error) { 84 | start := time.Now() 85 | result, err := s.Stmt.ExecContext(ctx, args...) 86 | if dur := time.Since(start); dur > longQueryDuration { 87 | s.log.Debug("slow exec", zap.String("query", s.query), zap.Duration("elapsed", dur), zap.Stack("stack")) 88 | } 89 | return result, err 90 | } 91 | 92 | func (s *stmt) Query(args ...any) (*sql.Rows, error) { 93 | return s.QueryContext(context.Background(), args...) 94 | } 95 | 96 | func (s *stmt) QueryContext(ctx context.Context, args ...any) (*sql.Rows, error) { 97 | start := time.Now() 98 | rows, err := s.Stmt.QueryContext(ctx, args...) 99 | if dur := time.Since(start); dur > longQueryDuration { 100 | s.log.Debug("slow query", zap.String("query", s.query), zap.Duration("elapsed", dur), zap.Stack("stack")) 101 | } 102 | return rows, err 103 | } 104 | 105 | func (s *stmt) QueryRow(args ...any) *row { 106 | return s.QueryRowContext(context.Background(), args...) 107 | } 108 | 109 | func (s *stmt) QueryRowContext(ctx context.Context, args ...any) *row { 110 | start := time.Now() 111 | r := s.Stmt.QueryRowContext(ctx, args...) 112 | if dur := time.Since(start); dur > longQueryDuration { 113 | s.log.Debug("slow query row", zap.String("query", s.query), zap.Duration("elapsed", dur), zap.Stack("stack")) 114 | } 115 | return &row{r, s.log.Named("row")} 116 | } 117 | 118 | // Exec executes a query without returning any rows. The args are for 119 | // any placeholder parameters in the query. 120 | func (tx *txn) Exec(query string, args ...any) (sql.Result, error) { 121 | start := time.Now() 122 | result, err := tx.Tx.Exec(query, args...) 123 | if dur := time.Since(start); dur > longQueryDuration { 124 | tx.log.Debug("slow exec", zap.String("query", query), zap.Duration("elapsed", dur), zap.Stack("stack")) 125 | } 126 | return result, err 127 | } 128 | 129 | // Prepare creates a prepared statement for later queries or executions. 130 | // Multiple queries or executions may be run concurrently from the 131 | // returned statement. The caller must call the statement's Close method 132 | // when the statement is no longer needed. 133 | func (tx *txn) Prepare(query string) (*stmt, error) { 134 | start := time.Now() 135 | s, err := tx.Tx.Prepare(query) 136 | if dur := time.Since(start); dur > longQueryDuration { 137 | tx.log.Debug("slow prepare", zap.String("query", query), zap.Duration("elapsed", dur), zap.Stack("stack")) 138 | } else if err != nil { 139 | return nil, err 140 | } 141 | return &stmt{ 142 | Stmt: s, 143 | query: query, 144 | log: tx.log.Named("statement"), 145 | }, nil 146 | } 147 | 148 | // Query executes a query that returns rows, typically a SELECT. The 149 | // args are for any placeholder parameters in the query. 150 | func (tx *txn) Query(query string, args ...any) (*rows, error) { 151 | start := time.Now() 152 | r, err := tx.Tx.Query(query, args...) 153 | if dur := time.Since(start); dur > longQueryDuration { 154 | tx.log.Debug("slow query", zap.String("query", query), zap.Duration("elapsed", dur), zap.Stack("stack")) 155 | } 156 | return &rows{r, tx.log.Named("rows")}, err 157 | } 158 | 159 | // QueryRow executes a query that is expected to return at most one row. 160 | // QueryRow always returns a non-nil value. Errors are deferred until 161 | // Row's Scan method is called. If the query selects no rows, the *Row's 162 | // Scan will return ErrNoRows. Otherwise, the *Row's Scan scans the 163 | // first selected row and discards the rest. 164 | func (tx *txn) QueryRow(query string, args ...any) *row { 165 | start := time.Now() 166 | r := tx.Tx.QueryRow(query, args...) 167 | if dur := time.Since(start); dur > longQueryDuration { 168 | tx.log.Debug("slow query row", zap.String("query", query), zap.Duration("elapsed", dur), zap.Stack("stack")) 169 | } 170 | return &row{r, tx.log.Named("row")} 171 | } 172 | 173 | // getDBVersion returns the current version of the database. 174 | func getDBVersion(db *sql.DB) (version int64) { 175 | // error is ignored -- the database may not have been initialized yet. 176 | db.QueryRow(`SELECT db_version FROM global_settings;`).Scan(&version) 177 | return 178 | } 179 | 180 | // setDBVersion sets the current version of the database. 181 | func setDBVersion(tx *txn, version int64) error { 182 | const query = `UPDATE global_settings SET db_version=$1 RETURNING id;` 183 | var dbID int64 184 | return tx.QueryRow(query, version).Scan(&dbID) 185 | } 186 | 187 | func queryPlaceHolders(n int) string { 188 | if n == 0 { 189 | return "" 190 | } 191 | return strings.Repeat("?,", n-1) + "?" 192 | } 193 | 194 | func encodeSlice[T any](args []T) []any { 195 | if len(args) == 0 { 196 | return nil 197 | } 198 | out := make([]any, len(args)) 199 | for i, arg := range args { 200 | out[i] = encode(arg) 201 | } 202 | return out 203 | } 204 | -------------------------------------------------------------------------------- /persist/sqlite/store.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | "time" 9 | 10 | "github.com/mattn/go-sqlite3" 11 | "go.sia.tech/walletd/v2/wallet" 12 | "go.uber.org/zap" 13 | ) 14 | 15 | type ( 16 | // A Store is a persistent store that uses a SQL database as its backend. 17 | Store struct { 18 | indexMode wallet.IndexMode 19 | spentElementRetentionBlocks uint64 // number of blocks to retain spent elements 20 | 21 | db *sql.DB 22 | log *zap.Logger 23 | } 24 | ) 25 | 26 | // Close closes the underlying database. 27 | func (s *Store) Close() error { 28 | return s.db.Close() 29 | } 30 | 31 | // transaction executes a function within a database transaction. If the 32 | // function returns an error, the transaction is rolled back. Otherwise, the 33 | // transaction is committed. 34 | func (s *Store) transaction(fn func(*txn) error) error { 35 | log := s.log.Named("transaction") 36 | 37 | start := time.Now() 38 | tx, err := s.db.Begin() 39 | if err != nil { 40 | return fmt.Errorf("failed to begin transaction: %w", err) 41 | } 42 | defer func() { 43 | if err := tx.Rollback(); err != nil && !errors.Is(err, sql.ErrTxDone) { 44 | log.Error("failed to rollback transaction", zap.Error(err)) 45 | } 46 | }() 47 | if err := fn(&txn{ 48 | Tx: tx, 49 | log: log, 50 | }); err != nil { 51 | return err 52 | } 53 | // log the transaction if it took longer than txn duration 54 | if time.Since(start) > longTxnDuration { 55 | log.Debug("long transaction", zap.Duration("elapsed", time.Since(start)), zap.Stack("stack"), zap.Bool("failed", err != nil)) 56 | } 57 | // commit the transaction 58 | if err := tx.Commit(); err != nil { 59 | return fmt.Errorf("failed to commit transaction: %w", err) 60 | } 61 | return nil 62 | } 63 | 64 | func sqliteFilepath(fp string) string { 65 | params := []string{ 66 | fmt.Sprintf("_busy_timeout=%d", time.Minute.Milliseconds()), 67 | "_foreign_keys=true", 68 | "_journal_mode=WAL", 69 | "_secure_delete=false", 70 | "_cache_size=-65536", // 64MiB 71 | } 72 | return "file:" + fp + "?" + strings.Join(params, "&") 73 | } 74 | 75 | // OpenDatabase creates a new SQLite store and initializes the database. If the 76 | // database does not exist, it is created. 77 | func OpenDatabase(fp string, opts ...Option) (*Store, error) { 78 | db, err := sql.Open("sqlite3", sqliteFilepath(fp)) 79 | if err != nil { 80 | return nil, err 81 | } 82 | // set the number of open connections to 1 to prevent "database is locked" 83 | // errors 84 | db.SetMaxOpenConns(1) 85 | 86 | store := &Store{ 87 | db: db, 88 | 89 | log: zap.NewNop(), 90 | spentElementRetentionBlocks: 144, // default to 144 blocks (1 day) 91 | } 92 | for _, opt := range opts { 93 | opt(store) 94 | } 95 | if err := store.init(); err != nil { 96 | return nil, err 97 | } 98 | sqliteVersion, _, _ := sqlite3.Version() 99 | store.log.Debug("database initialized", zap.String("sqliteVersion", sqliteVersion), zap.Int("schemaVersion", len(migrations)+1), zap.String("path", fp)) 100 | return store, nil 101 | } 102 | -------------------------------------------------------------------------------- /persist/sqlite/utxo.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "fmt" 7 | 8 | "go.sia.tech/core/types" 9 | "go.sia.tech/walletd/v2/wallet" 10 | ) 11 | 12 | func getSiacoinElement(tx *txn, id types.SiacoinOutputID, indexMode wallet.IndexMode) (ele types.SiacoinElement, err error) { 13 | const query = `SELECT se.id, se.siacoin_value, se.merkle_proof, se.leaf_index, se.maturity_height, sa.sia_address 14 | FROM siacoin_elements se 15 | INNER JOIN sia_addresses sa ON (se.address_id = sa.id) 16 | WHERE se.id=$1 AND spent_index_id IS NULL` 17 | 18 | ele, err = scanSiacoinElement(tx.QueryRow(query, encode(id))) 19 | if err != nil { 20 | return types.SiacoinElement{}, err 21 | } 22 | 23 | // retrieve the merkle proofs for the siacoin element 24 | if indexMode == wallet.IndexModeFull { 25 | proof, err := fillElementProofs(tx, []uint64{ele.StateElement.LeafIndex}) 26 | if err != nil { 27 | return types.SiacoinElement{}, fmt.Errorf("failed to fill element proofs: %w", err) 28 | } else if len(proof) != 1 { 29 | panic("expected exactly one proof") // should never happen 30 | } 31 | ele.StateElement.MerkleProof = proof[0] 32 | } 33 | return 34 | } 35 | 36 | func getSiafundElement(tx *txn, id types.SiafundOutputID, indexMode wallet.IndexMode) (ele types.SiafundElement, err error) { 37 | const query = `SELECT se.id, se.leaf_index, se.merkle_proof, se.siafund_value, se.claim_start, sa.sia_address 38 | FROM siafund_elements se 39 | INNER JOIN sia_addresses sa ON (se.address_id = sa.id) 40 | WHERE se.id=$1 AND spent_index_id IS NULL` 41 | 42 | ele, err = scanSiafundElement(tx.QueryRow(query, encode(id))) 43 | if err != nil { 44 | return types.SiafundElement{}, err 45 | } 46 | 47 | // retrieve the merkle proofs for the siafund element 48 | if indexMode == wallet.IndexModeFull { 49 | proof, err := fillElementProofs(tx, []uint64{ele.StateElement.LeafIndex}) 50 | if err != nil { 51 | return types.SiafundElement{}, fmt.Errorf("failed to fill element proofs: %w", err) 52 | } else if len(proof) != 1 { 53 | panic("expected exactly one proof") // should never happen 54 | } 55 | ele.StateElement.MerkleProof = proof[0] 56 | } 57 | return 58 | } 59 | 60 | // SiacoinElement returns an unspent Siacoin UTXO by its ID. 61 | func (s *Store) SiacoinElement(id types.SiacoinOutputID) (ele types.SiacoinElement, err error) { 62 | err = s.transaction(func(tx *txn) error { 63 | ele, err = getSiacoinElement(tx, id, s.indexMode) 64 | if errors.Is(err, sql.ErrNoRows) { 65 | return wallet.ErrNotFound 66 | } 67 | return err 68 | }) 69 | return 70 | } 71 | 72 | // SiafundElement returns an unspent Siafund UTXO by its ID. 73 | func (s *Store) SiafundElement(id types.SiafundOutputID) (ele types.SiafundElement, err error) { 74 | err = s.transaction(func(tx *txn) error { 75 | ele, err = getSiafundElement(tx, id, s.indexMode) 76 | if errors.Is(err, sql.ErrNoRows) { 77 | return wallet.ErrNotFound 78 | } 79 | return err 80 | }) 81 | return 82 | } 83 | 84 | // SiacoinElementSpentEvent returns the event that spent a Siacoin UTXO. 85 | func (s *Store) SiacoinElementSpentEvent(id types.SiacoinOutputID) (ev wallet.Event, spent bool, err error) { 86 | err = s.transaction(func(tx *txn) error { 87 | const query = `SELECT spent_event_id FROM siacoin_elements WHERE id=$1` 88 | 89 | var spentEventID sql.NullInt64 90 | err = tx.QueryRow(query, encode(id)).Scan(&spentEventID) 91 | if errors.Is(err, sql.ErrNoRows) { 92 | return wallet.ErrNotFound 93 | } else if err != nil { 94 | return fmt.Errorf("failed to query spent event ID: %w", err) 95 | } else if !spentEventID.Valid { 96 | return nil 97 | } 98 | 99 | spent = true 100 | events, err := getEventsByID(tx, []int64{spentEventID.Int64}) 101 | if err != nil { 102 | return fmt.Errorf("failed to get events by ID: %w", err) 103 | } else if len(events) != 1 { 104 | panic("expected exactly one event") // should never happen 105 | } 106 | ev = events[0] 107 | return nil 108 | }) 109 | return 110 | } 111 | 112 | // SiafundElementSpentEvent returns the event that spent a Siafund UTXO. 113 | func (s *Store) SiafundElementSpentEvent(id types.SiafundOutputID) (ev wallet.Event, spent bool, err error) { 114 | err = s.transaction(func(tx *txn) error { 115 | const query = `SELECT spent_event_id FROM siafund_elements WHERE id=$1` 116 | 117 | var spentEventID sql.NullInt64 118 | err = tx.QueryRow(query, encode(id)).Scan(&spentEventID) 119 | if errors.Is(err, sql.ErrNoRows) { 120 | return wallet.ErrNotFound 121 | } else if err != nil { 122 | return fmt.Errorf("failed to query spent event ID: %w", err) 123 | } else if !spentEventID.Valid { 124 | return nil 125 | } 126 | 127 | spent = true 128 | events, err := getEventsByID(tx, []int64{spentEventID.Int64}) 129 | if err != nil { 130 | return fmt.Errorf("failed to get events by ID: %w", err) 131 | } else if len(events) != 1 { 132 | panic("expected exactly one event") // should never happen 133 | } 134 | ev = events[0] 135 | return nil 136 | }) 137 | 138 | return 139 | } 140 | -------------------------------------------------------------------------------- /persist/sqlite/wallet_test.go: -------------------------------------------------------------------------------- 1 | package sqlite 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "reflect" 7 | "testing" 8 | 9 | "go.sia.tech/core/types" 10 | "go.sia.tech/walletd/v2/wallet" 11 | "go.uber.org/zap/zaptest" 12 | "lukechampine.com/frand" 13 | ) 14 | 15 | func TestAddAddresses(t *testing.T) { 16 | log := zaptest.NewLogger(t) 17 | 18 | // generate a large number of random addresses 19 | addresses := make([]wallet.Address, 1000) 20 | for i := range addresses { 21 | pk := types.GeneratePrivateKey() 22 | sp := types.PolicyPublicKey(pk.PublicKey()) 23 | addresses[i].Address = sp.Address() 24 | addresses[i].SpendPolicy = &sp 25 | addresses[i].Description = fmt.Sprintf("address %d", i) 26 | } 27 | 28 | // create a new database 29 | db, err := OpenDatabase(filepath.Join(t.TempDir(), "walletd.sqlite"), WithLog(log.Named("sqlite3"))) 30 | if err != nil { 31 | t.Fatal(err) 32 | } 33 | defer db.Close() 34 | 35 | w, err := db.AddWallet(wallet.Wallet{}) 36 | if err != nil { 37 | t.Fatal(err) 38 | } 39 | 40 | if err := db.AddWalletAddresses(w.ID, addresses...); err != nil { 41 | t.Fatal(err) 42 | } 43 | 44 | walletAddresses, err := db.WalletAddresses(w.ID) 45 | if err != nil { 46 | t.Fatal(err) 47 | } else if len(walletAddresses) != len(addresses) { 48 | t.Fatalf("expected %d addresses, got %d", len(addresses), len(walletAddresses)) 49 | } 50 | for i, addr := range walletAddresses { 51 | if !reflect.DeepEqual(addr, addresses[i]) { 52 | t.Fatalf("expected address %d to be %v, got %v", i, addresses[i], addr) 53 | } 54 | } 55 | 56 | // change random addresses' descriptions 57 | for range 10 { 58 | i := frand.Intn(len(addresses)) 59 | addresses[i].Description = fmt.Sprintf("updated address %d", i) 60 | } 61 | 62 | // add additional addresses 63 | for range 10 { 64 | pk := types.GeneratePrivateKey() 65 | sp := types.PolicyPublicKey(pk.PublicKey()) 66 | addresses = append(addresses, wallet.Address{ 67 | Address: sp.Address(), 68 | SpendPolicy: &sp, 69 | Description: fmt.Sprintf("address %d", len(addresses)), 70 | }) 71 | } 72 | 73 | // re-add the initial addresses and the new ones to ensure updates work 74 | if err := db.AddWalletAddresses(w.ID, addresses...); err != nil { 75 | t.Fatal(err) 76 | } 77 | walletAddresses, err = db.WalletAddresses(w.ID) 78 | if err != nil { 79 | t.Fatal(err) 80 | } else if len(walletAddresses) != len(addresses) { 81 | t.Fatalf("expected %d addresses, got %d", len(addresses), len(walletAddresses)) 82 | } 83 | for i, addr := range walletAddresses { 84 | if !reflect.DeepEqual(addr, addresses[i]) { 85 | t.Fatalf("expected address %d to be %v, got %v", i, addresses[i], addr) 86 | } 87 | } 88 | } 89 | 90 | func BenchmarkAddWalletAddresses(b *testing.B) { 91 | db, err := OpenDatabase(filepath.Join(b.TempDir(), "walletd.sqlite3")) 92 | if err != nil { 93 | b.Fatal(err) 94 | } 95 | defer db.Close() 96 | 97 | addresses := make([]wallet.Address, b.N) 98 | for i := range addresses { 99 | pk := types.GeneratePrivateKey() 100 | sp := types.PolicyPublicKey(pk.PublicKey()) 101 | addresses[i].Address = sp.Address() 102 | addresses[i].SpendPolicy = &sp 103 | addresses[i].Description = fmt.Sprintf("address %d", i) 104 | } 105 | 106 | w, err := db.AddWallet(wallet.Wallet{Name: "test"}) 107 | if err != nil { 108 | b.Fatal(err) 109 | } 110 | 111 | b.ResetTimer() 112 | b.ReportAllocs() 113 | 114 | if err := db.AddWalletAddresses(w.ID, addresses...); err != nil { 115 | b.Fatal(err) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /wallet/addresses.go: -------------------------------------------------------------------------------- 1 | package wallet 2 | 3 | import ( 4 | "time" 5 | 6 | "go.sia.tech/core/types" 7 | ) 8 | 9 | // CheckAddresses returns true if any of the addresses have been seen on the 10 | // blockchain. This is a quick way to scan wallets for lookaheads. 11 | func (m *Manager) CheckAddresses(address []types.Address) (bool, error) { 12 | return m.store.CheckAddresses(address) 13 | } 14 | 15 | // AddressBalance returns the balance of a single address. 16 | func (m *Manager) AddressBalance(addresses ...types.Address) (balance Balance, err error) { 17 | return m.store.AddressBalance(addresses...) 18 | } 19 | 20 | // AddressSiacoinOutputs returns the unspent siacoin outputs for an address. 21 | func (m *Manager) AddressSiacoinOutputs(address types.Address, usePool bool, offset, limit int) ([]UnspentSiacoinElement, types.ChainIndex, error) { 22 | if !usePool { 23 | return m.store.AddressSiacoinOutputs(address, nil, offset, limit) 24 | } 25 | 26 | m.mu.Lock() 27 | defer m.mu.Unlock() 28 | 29 | spent := m.poolAddressSCSpent[address] 30 | var created []UnspentSiacoinElement 31 | for _, sce := range m.poolSCCreated { 32 | if sce.SiacoinOutput.Address != address { 33 | continue 34 | } 35 | 36 | sce.StateElement = sce.StateElement.Copy() 37 | created = append(created, UnspentSiacoinElement{ 38 | SiacoinElement: sce, 39 | }) 40 | } 41 | 42 | outputs, basis, err := m.store.AddressSiacoinOutputs(address, spent, offset, limit) 43 | if err != nil { 44 | return nil, types.ChainIndex{}, err 45 | } else if len(outputs) == limit { 46 | return outputs, basis, nil 47 | } 48 | return append(outputs, created...), basis, nil 49 | } 50 | 51 | // AddressSiafundOutputs returns the unspent siafund outputs for an address. 52 | func (m *Manager) AddressSiafundOutputs(address types.Address, usePool bool, offset, limit int) ([]UnspentSiafundElement, types.ChainIndex, error) { 53 | if !usePool { 54 | return m.store.AddressSiafundOutputs(address, nil, offset, limit) 55 | } 56 | 57 | m.mu.Lock() 58 | defer m.mu.Unlock() 59 | 60 | spent := m.poolAddressSFSpent[address] 61 | var created []UnspentSiafundElement 62 | for _, sfe := range m.poolSFCreated { 63 | if sfe.SiafundOutput.Address != address { 64 | continue 65 | } 66 | sfe.StateElement = sfe.StateElement.Copy() 67 | created = append(created, UnspentSiafundElement{ 68 | SiafundElement: sfe, 69 | }) 70 | } 71 | 72 | outputs, basis, err := m.store.AddressSiafundOutputs(address, spent, offset, limit) 73 | if err != nil { 74 | return nil, types.ChainIndex{}, err 75 | } else if len(outputs) == limit { 76 | return outputs, basis, nil 77 | } 78 | return append(outputs, created...), basis, nil 79 | } 80 | 81 | // AddressEvents returns the events of a single address. 82 | func (m *Manager) AddressEvents(address types.Address, offset, limit int) (events []Event, err error) { 83 | return m.store.AddressEvents(address, offset, limit) 84 | } 85 | 86 | // BatchAddressEvents returns the events for a batch of addresses. 87 | func (m *Manager) BatchAddressEvents(addresses []types.Address, offset, limit int) ([]Event, error) { 88 | if len(addresses) == 0 { 89 | return nil, nil // no addresses, no events 90 | } 91 | return m.store.BatchAddressEvents(addresses, offset, limit) 92 | } 93 | 94 | // BatchAddressSiacoinOutputs returns the unspent siacoin outputs for a batch of addresses. 95 | func (m *Manager) BatchAddressSiacoinOutputs(addresses []types.Address, offset, limit int) ([]UnspentSiacoinElement, types.ChainIndex, error) { 96 | if len(addresses) == 0 { 97 | return nil, types.ChainIndex{}, nil // no addresses, no outputs 98 | } 99 | return m.store.BatchAddressSiacoinOutputs(addresses, offset, limit) 100 | } 101 | 102 | // BatchAddressSiafundOutputs returns the unspent siafund outputs for a batch of addresses. 103 | func (m *Manager) BatchAddressSiafundOutputs(addresses []types.Address, offset, limit int) ([]UnspentSiafundElement, types.ChainIndex, error) { 104 | if len(addresses) == 0 { 105 | return nil, types.ChainIndex{}, nil // no addresses, no outputs 106 | } 107 | return m.store.BatchAddressSiafundOutputs(addresses, offset, limit) 108 | } 109 | 110 | // AddressUnconfirmedEvents returns the unconfirmed events for a single address. 111 | func (m *Manager) AddressUnconfirmedEvents(address types.Address) ([]Event, error) { 112 | index := m.chain.Tip() 113 | index.Height++ 114 | index.ID = types.BlockID{} 115 | timestamp := time.Now() 116 | 117 | v1, v2 := m.chain.PoolTransactions(), m.chain.V2PoolTransactions() 118 | 119 | relevantV1Txn := func(txn types.Transaction) bool { 120 | for _, output := range txn.SiacoinOutputs { 121 | if output.Address == address { 122 | return true 123 | } 124 | } 125 | for _, input := range txn.SiacoinInputs { 126 | if input.UnlockConditions.UnlockHash() == address { 127 | return true 128 | } 129 | } 130 | for _, output := range txn.SiafundOutputs { 131 | if output.Address == address { 132 | return true 133 | } 134 | } 135 | for _, input := range txn.SiafundInputs { 136 | if input.UnlockConditions.UnlockHash() == address { 137 | return true 138 | } 139 | } 140 | return false 141 | } 142 | 143 | relevantV1 := v1[:0] 144 | for _, txn := range v1 { 145 | if !relevantV1Txn(txn) { 146 | continue 147 | } 148 | relevantV1 = append(relevantV1, txn) 149 | } 150 | 151 | events, err := m.store.AnnotateV1Events(index, timestamp, relevantV1) 152 | if err != nil { 153 | return nil, err 154 | } 155 | 156 | for i := range events { 157 | events[i].Relevant = []types.Address{address} 158 | } 159 | 160 | relevantV2Txn := func(txn types.V2Transaction) bool { 161 | for _, output := range txn.SiacoinOutputs { 162 | if output.Address == address { 163 | return true 164 | } 165 | } 166 | for _, input := range txn.SiacoinInputs { 167 | if input.Parent.SiacoinOutput.Address == address { 168 | return true 169 | } 170 | } 171 | for _, output := range txn.SiafundOutputs { 172 | if output.Address == address { 173 | return true 174 | } 175 | } 176 | for _, input := range txn.SiafundInputs { 177 | if input.Parent.SiafundOutput.Address == address { 178 | return true 179 | } 180 | } 181 | return false 182 | } 183 | 184 | // Annotate v2 transactions. 185 | for _, txn := range v2 { 186 | if !relevantV2Txn(txn) { 187 | continue 188 | } 189 | 190 | events = append(events, Event{ 191 | ID: types.Hash256(txn.ID()), 192 | Index: index, 193 | Timestamp: timestamp, 194 | MaturityHeight: index.Height, 195 | Type: EventTypeV2Transaction, 196 | Data: EventV2Transaction(txn), 197 | Relevant: []types.Address{address}, 198 | }) 199 | } 200 | return events, nil 201 | } 202 | -------------------------------------------------------------------------------- /wallet/addresses_test.go: -------------------------------------------------------------------------------- 1 | package wallet_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "go.sia.tech/core/types" 7 | "go.sia.tech/walletd/v2/internal/testutil" 8 | "go.sia.tech/walletd/v2/wallet" 9 | "go.uber.org/zap/zaptest" 10 | "lukechampine.com/frand" 11 | ) 12 | 13 | func TestAddressUseTpool(t *testing.T) { 14 | log := zaptest.NewLogger(t) 15 | 16 | // mine a single payout to the wallet 17 | pk := types.GeneratePrivateKey() 18 | uc := types.StandardUnlockConditions(pk.PublicKey()) 19 | addr1 := uc.UnlockHash() 20 | 21 | network, genesisBlock := testutil.V2Network() 22 | genesisBlock.Transactions[0].SiacoinOutputs = []types.SiacoinOutput{ 23 | {Address: addr1, Value: types.Siacoins(100)}, 24 | } 25 | cn := testutil.NewConsensusNode(t, network, genesisBlock, log) 26 | cm := cn.Chain 27 | db := cn.Store 28 | 29 | wm, err := wallet.NewManager(cm, db, wallet.WithLogger(log.Named("wallet")), wallet.WithIndexMode(wallet.IndexModeFull)) 30 | if err != nil { 31 | t.Fatal(err) 32 | } 33 | defer wm.Close() 34 | 35 | cn.MineBlocks(t, types.VoidAddress, 1) 36 | 37 | assertSiacoinElement := func(t *testing.T, id types.SiacoinOutputID, value types.Currency, confirmations uint64) { 38 | t.Helper() 39 | 40 | utxos, _, err := wm.AddressSiacoinOutputs(addr1, true, 0, 1) 41 | if err != nil { 42 | t.Fatal(err) 43 | } 44 | for _, sce := range utxos { 45 | if sce.ID == id { 46 | if !sce.SiacoinOutput.Value.Equals(value) { 47 | t.Fatalf("expected value %v, got %v", value, sce.SiacoinOutput.Value) 48 | } else if sce.Confirmations != confirmations { 49 | t.Fatalf("expected confirmations %d, got %d", confirmations, sce.Confirmations) 50 | } 51 | return 52 | } 53 | } 54 | t.Fatalf("expected siacoin element with ID %q not found", id) 55 | } 56 | 57 | airdropID := genesisBlock.Transactions[0].SiacoinOutputID(0) 58 | assertSiacoinElement(t, airdropID, types.Siacoins(100), 2) 59 | 60 | utxos, basis, err := wm.AddressSiacoinOutputs(addr1, true, 0, 100) 61 | if err != nil { 62 | t.Fatal(err) 63 | } 64 | 65 | cs := cm.TipState() 66 | txn := types.V2Transaction{ 67 | SiacoinInputs: []types.V2SiacoinInput{ 68 | { 69 | Parent: utxos[0].SiacoinElement, 70 | SatisfiedPolicy: types.SatisfiedPolicy{ 71 | Policy: types.SpendPolicy{ 72 | Type: types.PolicyTypeUnlockConditions(uc), 73 | }, 74 | }, 75 | }, 76 | }, 77 | SiacoinOutputs: []types.SiacoinOutput{ 78 | { 79 | Address: types.VoidAddress, 80 | Value: types.Siacoins(25), 81 | }, 82 | { 83 | Address: addr1, 84 | Value: types.Siacoins(75), 85 | }, 86 | }, 87 | } 88 | sigHash := cs.InputSigHash(txn) 89 | txn.SiacoinInputs[0].SatisfiedPolicy.Signatures = []types.Signature{ 90 | pk.SignHash(sigHash), 91 | } 92 | 93 | if _, err := cm.AddV2PoolTransactions(basis, []types.V2Transaction{txn}); err != nil { 94 | t.Fatal(err) 95 | } 96 | wm.SyncPool() // force reindexing of the tpool 97 | assertSiacoinElement(t, txn.SiacoinOutputID(txn.ID(), 1), types.Siacoins(75), 0) 98 | cn.MineBlocks(t, types.VoidAddress, 1) 99 | assertSiacoinElement(t, txn.SiacoinOutputID(txn.ID(), 1), types.Siacoins(75), 1) 100 | } 101 | 102 | func TestBatchAddresses(t *testing.T) { 103 | log := zaptest.NewLogger(t) 104 | 105 | network, genesisBlock := testutil.V2Network() 106 | cn := testutil.NewConsensusNode(t, network, genesisBlock, log) 107 | cm := cn.Chain 108 | db := cn.Store 109 | 110 | wm, err := wallet.NewManager(cm, db, wallet.WithLogger(log.Named("wallet")), wallet.WithIndexMode(wallet.IndexModeFull)) 111 | if err != nil { 112 | t.Fatal(err) 113 | } 114 | defer wm.Close() 115 | 116 | // mine a bunch of payouts to different addresses 117 | addresses := make([]types.Address, 100) 118 | for i := range addresses { 119 | addresses[i] = types.StandardAddress(types.GeneratePrivateKey().PublicKey()) 120 | cn.MineBlocks(t, addresses[i], 1) 121 | } 122 | 123 | events, err := wm.BatchAddressEvents(addresses, 0, 1000) 124 | if err != nil { 125 | t.Fatal(err) 126 | } else if len(events) != 100 { 127 | t.Fatalf("expected 100 events, got %d", len(events)) 128 | } 129 | } 130 | 131 | func TestBatchSiacoinOutputs(t *testing.T) { 132 | log := zaptest.NewLogger(t) 133 | 134 | network, genesisBlock := testutil.V2Network() 135 | cn := testutil.NewConsensusNode(t, network, genesisBlock, log) 136 | cm := cn.Chain 137 | db := cn.Store 138 | 139 | wm, err := wallet.NewManager(cm, db, wallet.WithLogger(log.Named("wallet")), wallet.WithIndexMode(wallet.IndexModeFull)) 140 | if err != nil { 141 | t.Fatal(err) 142 | } 143 | defer wm.Close() 144 | 145 | // mine a bunch of payouts to different addresses 146 | addresses := make([]types.Address, 100) 147 | for i := range addresses { 148 | addresses[i] = types.StandardAddress(types.GeneratePrivateKey().PublicKey()) 149 | cn.MineBlocks(t, addresses[i], 1) 150 | } 151 | cn.MineBlocks(t, types.VoidAddress, int(network.MaturityDelay)) 152 | 153 | sces, _, err := wm.BatchAddressSiacoinOutputs(addresses, 0, 1000) 154 | if err != nil { 155 | t.Fatal(err) 156 | } else if len(sces) != 100 { 157 | t.Fatalf("expected 100 events, got %d", len(sces)) 158 | } 159 | } 160 | 161 | func TestBatchSiafundOutputs(t *testing.T) { 162 | log := zaptest.NewLogger(t) 163 | 164 | giftAddr := types.AnyoneCanSpend().Address() 165 | network, genesisBlock := testutil.V2Network() 166 | genesisBlock.Transactions[0].SiafundOutputs = []types.SiafundOutput{ 167 | {Address: giftAddr, Value: 10000}, 168 | } 169 | cn := testutil.NewConsensusNode(t, network, genesisBlock, log) 170 | cm := cn.Chain 171 | db := cn.Store 172 | 173 | wm, err := wallet.NewManager(cm, db, wallet.WithLogger(log.Named("wallet")), wallet.WithIndexMode(wallet.IndexModeFull)) 174 | if err != nil { 175 | t.Fatal(err) 176 | } 177 | defer wm.Close() 178 | 179 | // distribute the siafund output to multiple addresses 180 | var addresses []types.Address 181 | outputID := genesisBlock.Transactions[0].SiafundOutputID(0) 182 | outputValue := genesisBlock.Transactions[0].SiafundOutputs[0].Value 183 | for range 100 { 184 | txn := types.V2Transaction{ 185 | SiafundInputs: []types.V2SiafundInput{ 186 | { 187 | Parent: types.SiafundElement{ 188 | ID: outputID, 189 | }, 190 | SatisfiedPolicy: types.SatisfiedPolicy{ 191 | Policy: types.AnyoneCanSpend(), 192 | }, 193 | }, 194 | }, 195 | } 196 | 197 | for range 10 { 198 | address := types.StandardAddress(types.GeneratePrivateKey().PublicKey()) 199 | addresses = append(addresses, address) 200 | txn.SiafundOutputs = append(txn.SiafundOutputs, types.SiafundOutput{ 201 | Address: address, 202 | Value: 1, 203 | }) 204 | outputValue-- 205 | if outputValue == 0 { 206 | break 207 | } 208 | } 209 | 210 | if outputValue > 0 { 211 | txn.SiafundOutputs = append(txn.SiafundOutputs, types.SiafundOutput{ 212 | Address: giftAddr, 213 | Value: outputValue, 214 | }) 215 | } 216 | outputID = txn.SiafundOutputID(txn.ID(), len(txn.SiafundOutputs)-1) 217 | basis, txns, err := db.OverwriteElementProofs([]types.V2Transaction{txn}) 218 | if err != nil { 219 | t.Fatal(err) 220 | } 221 | if _, err := cm.AddV2PoolTransactions(basis, txns); err != nil { 222 | t.Fatal(err) 223 | } 224 | cn.MineBlocks(t, types.VoidAddress, 1) 225 | cn.WaitForSync(t) 226 | } 227 | 228 | sfes, _, err := wm.BatchAddressSiafundOutputs(addresses, 0, 10000) 229 | if err != nil { 230 | t.Fatal(err) 231 | } else if len(sfes) != 1000 { 232 | t.Fatalf("expected 1000 events, got %d", len(sfes)) 233 | } 234 | } 235 | 236 | func BenchmarkBatchAddresses(b *testing.B) { 237 | log := zaptest.NewLogger(b) 238 | 239 | network, genesisBlock := testutil.V2Network() 240 | cn := testutil.NewConsensusNode(b, network, genesisBlock, log) 241 | cm := cn.Chain 242 | db := cn.Store 243 | 244 | wm, err := wallet.NewManager(cm, db, wallet.WithLogger(log.Named("wallet")), wallet.WithIndexMode(wallet.IndexModeFull)) 245 | if err != nil { 246 | b.Fatal(err) 247 | } 248 | defer wm.Close() 249 | 250 | // mine a bunch of payouts to different addresses 251 | addresses := make([]types.Address, 10000) 252 | for i := range addresses { 253 | addresses[i] = types.StandardAddress(types.GeneratePrivateKey().PublicKey()) 254 | cn.MineBlocks(b, addresses[i], 1) 255 | } 256 | 257 | b.ResetTimer() 258 | b.ReportAllocs() 259 | 260 | for b.Loop() { 261 | slice := addresses[frand.Intn(len(addresses)-1000):][:1000] 262 | events, err := wm.BatchAddressEvents(slice, 0, 100) 263 | if err != nil { 264 | b.Fatal(err) 265 | } else if len(events) != 100 { 266 | b.Fatalf("expected 100 events, got %d", len(events)) 267 | } 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /wallet/manager_test.go: -------------------------------------------------------------------------------- 1 | package wallet_test 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "go.sia.tech/core/types" 8 | "go.sia.tech/walletd/v2/internal/testutil" 9 | "go.sia.tech/walletd/v2/wallet" 10 | "go.uber.org/zap/zaptest" 11 | ) 12 | 13 | func TestHealth(t *testing.T) { 14 | log := zaptest.NewLogger(t) 15 | n, genesis := testutil.V2Network() 16 | cn := testutil.NewConsensusNode(t, n, genesis, log) 17 | cm := cn.Chain 18 | 19 | wm, err := wallet.NewManager(cm, cn.Store) 20 | if err != nil { 21 | t.Fatal(err) 22 | } 23 | defer wm.Close() 24 | 25 | if err := wm.Health(); !errors.Is(err, wallet.ErrNotSyncing) { 26 | t.Fatalf("expected error %q, got %q", wallet.ErrNotSyncing, err) 27 | } 28 | 29 | cn.MineBlocks(t, types.VoidAddress, 1) 30 | 31 | if err := wm.Health(); err != nil { 32 | t.Fatalf("expected no error, got %v", err) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /wallet/options.go: -------------------------------------------------------------------------------- 1 | package wallet 2 | 3 | import ( 4 | "time" 5 | 6 | "go.uber.org/zap" 7 | ) 8 | 9 | // An Option configures a wallet Manager. 10 | type Option func(*Manager) 11 | 12 | // WithLogger sets the logger used by the manager. 13 | func WithLogger(log *zap.Logger) Option { 14 | return func(m *Manager) { 15 | m.log = log 16 | } 17 | } 18 | 19 | // WithIndexMode sets the index mode used by the manager. 20 | func WithIndexMode(mode IndexMode) Option { 21 | return func(m *Manager) { 22 | m.indexMode = mode 23 | } 24 | } 25 | 26 | // WithSyncBatchSize sets the number of blocks to batch when scanning 27 | // the blockchain. The default is 64. Increasing this value can 28 | // improve performance at the cost of memory usage. 29 | func WithSyncBatchSize(size int) Option { 30 | return func(m *Manager) { 31 | m.syncBatchSize = size 32 | } 33 | } 34 | 35 | // WithLockDuration sets the duration that a UTXO is locked after 36 | // being selected as an input to a transaction. The default is 1 hour. 37 | func WithLockDuration(d time.Duration) Option { 38 | return func(m *Manager) { 39 | m.lockDuration = d 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /wallet/seed.go: -------------------------------------------------------------------------------- 1 | package wallet 2 | 3 | import ( 4 | "crypto/ed25519" 5 | "encoding/json" 6 | "fmt" 7 | "sync" 8 | 9 | "go.sia.tech/core/consensus" 10 | "go.sia.tech/core/types" 11 | "go.sia.tech/coreutils/wallet" 12 | "lukechampine.com/frand" 13 | ) 14 | 15 | // A Seed is securely-generated entropy, used to derive an arbitrary number of 16 | // keypairs. 17 | type Seed struct { 18 | entropy *[32]byte 19 | } 20 | 21 | // PublicKey derives the public key for the specified index. 22 | func (s Seed) PublicKey(index uint64) (pk types.PublicKey) { 23 | key := wallet.KeyFromSeed(s.entropy, index) 24 | copy(pk[:], key[len(key)-ed25519.PublicKeySize:]) 25 | return 26 | } 27 | 28 | // PrivateKey derives the private key for the specified index. 29 | func (s Seed) PrivateKey(index uint64) types.PrivateKey { 30 | key := wallet.KeyFromSeed(s.entropy, index) 31 | return key[:] 32 | } 33 | 34 | // NewSeed returns a random Seed. 35 | func NewSeed() Seed { 36 | var entropy [32]byte 37 | frand.Read(entropy[:]) 38 | return NewSeedFromEntropy(&entropy) 39 | } 40 | 41 | // NewSeedFromEntropy returns the specified seed. 42 | func NewSeedFromEntropy(entropy *[32]byte) Seed { 43 | return Seed{entropy} 44 | } 45 | 46 | // A SeedAddressVault generates and stores addresses from a seed. 47 | type SeedAddressVault struct { 48 | seed Seed 49 | lookahead uint64 50 | addrs map[types.Address]uint64 51 | mu sync.Mutex 52 | } 53 | 54 | func (sav *SeedAddressVault) gen(index uint64) { 55 | for index > uint64(len(sav.addrs)) { 56 | sav.addrs[types.StandardAddress(sav.seed.PublicKey(uint64(len(sav.addrs))))] = uint64(len(sav.addrs)) 57 | } 58 | } 59 | 60 | // OwnsAddress returns true if addr was derived from the seed. 61 | func (sav *SeedAddressVault) OwnsAddress(addr types.Address) bool { 62 | sav.mu.Lock() 63 | defer sav.mu.Unlock() 64 | index, ok := sav.addrs[addr] 65 | if ok { 66 | sav.gen(index + sav.lookahead) 67 | } 68 | return ok 69 | } 70 | 71 | // NewAddress returns a new address derived from the seed, along with 72 | // descriptive metadata. 73 | func (sav *SeedAddressVault) NewAddress(desc string) Address { 74 | sav.mu.Lock() 75 | defer sav.mu.Unlock() 76 | index := uint64(len(sav.addrs)) - sav.lookahead + 1 77 | sav.gen(index + sav.lookahead) 78 | policy := types.PolicyPublicKey(sav.seed.PublicKey(index)) 79 | addr := policy.Address() 80 | return Address{ 81 | Address: addr, 82 | Description: desc, 83 | SpendPolicy: &policy, 84 | Metadata: json.RawMessage(fmt.Sprintf(`{"keyIndex":%d}`, index)), 85 | } 86 | } 87 | 88 | // SignTransaction signs the specified transaction using keys derived from the 89 | // wallet seed. If toSign is nil, SignTransaction will automatically add 90 | // Signatures for each input owned by the seed. If toSign is not nil, it a list 91 | // of IDs of Signatures already present in txn; SignTransaction will fill in the 92 | // Signature field of each. 93 | func (sav *SeedAddressVault) SignTransaction(cs consensus.State, txn *types.Transaction, toSign []types.Hash256) error { 94 | sav.mu.Lock() 95 | defer sav.mu.Unlock() 96 | 97 | if len(toSign) == 0 { 98 | // lazy mode: add standard sigs for every input we own 99 | for _, sci := range txn.SiacoinInputs { 100 | if index, ok := sav.addrs[sci.UnlockConditions.UnlockHash()]; ok { 101 | txn.Signatures = append(txn.Signatures, StandardTransactionSignature(types.Hash256(sci.ParentID))) 102 | SignTransaction(cs, txn, len(txn.Signatures)-1, sav.seed.PrivateKey(index)) 103 | } 104 | } 105 | for _, sfi := range txn.SiafundInputs { 106 | if index, ok := sav.addrs[sfi.UnlockConditions.UnlockHash()]; ok { 107 | txn.Signatures = append(txn.Signatures, StandardTransactionSignature(types.Hash256(sfi.ParentID))) 108 | SignTransaction(cs, txn, len(txn.Signatures)-1, sav.seed.PrivateKey(index)) 109 | } 110 | } 111 | return nil 112 | } 113 | 114 | sigAddr := func(id types.Hash256) (types.Address, bool) { 115 | for _, sci := range txn.SiacoinInputs { 116 | if types.Hash256(sci.ParentID) == id { 117 | return sci.UnlockConditions.UnlockHash(), true 118 | } 119 | } 120 | for _, sfi := range txn.SiafundInputs { 121 | if types.Hash256(sfi.ParentID) == id { 122 | return sfi.UnlockConditions.UnlockHash(), true 123 | } 124 | } 125 | for _, fcr := range txn.FileContractRevisions { 126 | if types.Hash256(fcr.ParentID) == id { 127 | return fcr.UnlockConditions.UnlockHash(), true 128 | } 129 | } 130 | return types.Address{}, false 131 | } 132 | 133 | outer: 134 | for _, parent := range toSign { 135 | for sigIndex, sig := range txn.Signatures { 136 | if sig.ParentID == parent { 137 | if addr, ok := sigAddr(parent); !ok { 138 | return fmt.Errorf("ID %v not present in transaction", parent) 139 | } else if index, ok := sav.addrs[addr]; !ok { 140 | return fmt.Errorf("missing key for ID %v", parent) 141 | } else { 142 | SignTransaction(cs, txn, sigIndex, sav.seed.PrivateKey(index)) 143 | continue outer 144 | } 145 | } 146 | } 147 | return fmt.Errorf("signature %v not present in transaction", parent) 148 | } 149 | return nil 150 | } 151 | 152 | // NewSeedAddressVault initializes a SeedAddressVault. 153 | func NewSeedAddressVault(seed Seed, initialAddrs, lookahead uint64) *SeedAddressVault { 154 | sav := &SeedAddressVault{ 155 | seed: seed, 156 | lookahead: lookahead, 157 | addrs: make(map[types.Address]uint64), 158 | } 159 | sav.gen(initialAddrs + lookahead) 160 | return sav 161 | } 162 | -------------------------------------------------------------------------------- /wallet/update.go: -------------------------------------------------------------------------------- 1 | package wallet 2 | 3 | import ( 4 | "fmt" 5 | 6 | "go.sia.tech/core/types" 7 | "go.sia.tech/coreutils/chain" 8 | "go.uber.org/zap" 9 | ) 10 | 11 | type ( 12 | // A stateTreeUpdater is an interface for applying and reverting 13 | // Merkle tree updates. 14 | stateTreeUpdater interface { 15 | UpdateElementProof(*types.StateElement) 16 | ForEachTreeNode(fn func(row uint64, col uint64, h types.Hash256)) 17 | } 18 | 19 | // A ProofUpdater is an interface for updating Merkle proofs. 20 | ProofUpdater interface { 21 | UpdateElementProof(*types.StateElement) 22 | } 23 | 24 | // AddressBalance pairs an address with its balance. 25 | AddressBalance struct { 26 | Address types.Address `json:"address"` 27 | Balance 28 | } 29 | 30 | // SpentSiacoinElement pairs a spent siacoin element with the ID of the 31 | // transaction that spent it. 32 | SpentSiacoinElement struct { 33 | types.SiacoinElement 34 | EventID types.TransactionID 35 | } 36 | 37 | // SpentSiafundElement pairs a spent siafund element with the ID of the 38 | // transaction that spent it. 39 | SpentSiafundElement struct { 40 | types.SiafundElement 41 | EventID types.TransactionID 42 | } 43 | 44 | // AppliedState contains all state changes made to a store after applying a chain 45 | // update. 46 | AppliedState struct { 47 | NumLeaves uint64 48 | Events []Event 49 | CreatedSiacoinElements []types.SiacoinElement 50 | SpentSiacoinElements []SpentSiacoinElement 51 | CreatedSiafundElements []types.SiafundElement 52 | SpentSiafundElements []SpentSiafundElement 53 | } 54 | 55 | // RevertedState contains all state changes made to a store after reverting 56 | // a chain update. 57 | RevertedState struct { 58 | NumLeaves uint64 59 | UnspentSiacoinElements []types.SiacoinElement 60 | DeletedSiacoinElements []types.SiacoinElement 61 | UnspentSiafundElements []types.SiafundElement 62 | DeletedSiafundElements []types.SiafundElement 63 | } 64 | 65 | // A TreeNodeUpdate contains the hash of a Merkle tree node and its row and 66 | // column indices. 67 | TreeNodeUpdate struct { 68 | Hash types.Hash256 69 | Row int 70 | Column int 71 | } 72 | 73 | // An UpdateTx atomically updates the state of a store. 74 | UpdateTx interface { 75 | UpdateStateElementProofs(ProofUpdater) error 76 | UpdateStateTree([]TreeNodeUpdate) error 77 | 78 | AddressRelevant(types.Address) (bool, error) 79 | 80 | ApplyIndex(types.ChainIndex, AppliedState) error 81 | RevertIndex(types.ChainIndex, RevertedState) error 82 | } 83 | ) 84 | 85 | // updateStateElements updates the state elements in a store according to the 86 | // changes made by a chain update. 87 | func updateStateElements(tx UpdateTx, update stateTreeUpdater, indexMode IndexMode) error { 88 | if indexMode == IndexModeNone { 89 | panic("updateStateElements called with IndexModeNone") // developer error 90 | } 91 | 92 | if indexMode == IndexModeFull { 93 | var updates []TreeNodeUpdate 94 | update.ForEachTreeNode(func(row, col uint64, h types.Hash256) { 95 | updates = append(updates, TreeNodeUpdate{h, int(row), int(col)}) 96 | }) 97 | return tx.UpdateStateTree(updates) 98 | } else { 99 | return tx.UpdateStateElementProofs(update) 100 | } 101 | } 102 | 103 | // applyChainUpdate atomically applies a chain update to a store 104 | func applyChainUpdate(tx UpdateTx, cau chain.ApplyUpdate, indexMode IndexMode) error { 105 | applied := AppliedState{ 106 | NumLeaves: cau.State.Elements.NumLeaves, 107 | } 108 | 109 | spentEventIDs := make(map[types.Hash256]types.TransactionID) 110 | for _, txn := range cau.Block.Transactions { 111 | txnID := txn.ID() 112 | for _, input := range txn.SiacoinInputs { 113 | spentEventIDs[types.Hash256(input.ParentID)] = txnID 114 | } 115 | for _, input := range txn.SiafundInputs { 116 | spentEventIDs[types.Hash256(input.ParentID)] = txnID 117 | } 118 | } 119 | for _, txn := range cau.Block.V2Transactions() { 120 | txnID := txn.ID() 121 | for _, input := range txn.SiacoinInputs { 122 | spentEventIDs[types.Hash256(input.Parent.ID)] = txnID 123 | } 124 | for _, input := range txn.SiafundInputs { 125 | spentEventIDs[types.Hash256(input.Parent.ID)] = txnID 126 | } 127 | } 128 | 129 | // add new siacoin elements to the store 130 | for _, sced := range cau.SiacoinElementDiffs() { 131 | sce := sced.SiacoinElement 132 | if (sced.Created && sced.Spent) || sce.SiacoinOutput.Value.IsZero() { 133 | continue 134 | } else if relevant, err := tx.AddressRelevant(sce.SiacoinOutput.Address); err != nil { 135 | panic(err) 136 | } else if !relevant { 137 | continue 138 | } 139 | if sced.Spent { 140 | spentTxnID, ok := spentEventIDs[types.Hash256(sce.ID)] 141 | if !ok { 142 | panic(fmt.Errorf("missing transaction ID for spent siacoin element %v", sce.ID)) 143 | } 144 | applied.SpentSiacoinElements = append(applied.SpentSiacoinElements, SpentSiacoinElement{ 145 | SiacoinElement: sce, 146 | EventID: spentTxnID, 147 | }) 148 | } else { 149 | applied.CreatedSiacoinElements = append(applied.CreatedSiacoinElements, sce) 150 | } 151 | } 152 | for _, sfed := range cau.SiafundElementDiffs() { 153 | sfe := sfed.SiafundElement 154 | if (sfed.Created && sfed.Spent) || sfe.SiafundOutput.Value == 0 { 155 | continue 156 | } else if relevant, err := tx.AddressRelevant(sfe.SiafundOutput.Address); err != nil { 157 | panic(err) 158 | } else if !relevant { 159 | continue 160 | } 161 | if sfed.Spent { 162 | spentTxnID, ok := spentEventIDs[types.Hash256(sfe.ID)] 163 | if !ok { 164 | panic(fmt.Errorf("missing transaction ID for spent siafund element %v", sfe.ID)) 165 | } 166 | applied.SpentSiafundElements = append(applied.SpentSiafundElements, SpentSiafundElement{ 167 | SiafundElement: sfe, 168 | EventID: spentTxnID, 169 | }) 170 | } else { 171 | applied.CreatedSiafundElements = append(applied.CreatedSiafundElements, sfe) 172 | } 173 | } 174 | 175 | // add events 176 | relevant := func(addr types.Address) bool { 177 | relevant, err := tx.AddressRelevant(addr) 178 | if err != nil { 179 | panic(fmt.Errorf("failed to check if address is relevant: %w", err)) 180 | } 181 | return relevant 182 | } 183 | applied.Events = AppliedEvents(cau.State, cau.Block, cau, relevant) 184 | 185 | if err := updateStateElements(tx, cau, indexMode); err != nil { 186 | return fmt.Errorf("failed to update state elements: %w", err) 187 | } else if err := tx.ApplyIndex(cau.State.Index, applied); err != nil { 188 | return fmt.Errorf("failed to apply index: %w", err) 189 | } 190 | return nil 191 | } 192 | 193 | // revertChainUpdate atomically reverts a chain update from a store 194 | func revertChainUpdate(tx UpdateTx, cru chain.RevertUpdate, revertedIndex types.ChainIndex, indexMode IndexMode) error { 195 | reverted := RevertedState{ 196 | NumLeaves: cru.State.Elements.NumLeaves, 197 | } 198 | 199 | // determine which siacoin and siafund elements are ephemeral 200 | // 201 | // note: I thought we could use LeafIndex == EphemeralLeafIndex, but 202 | // it seems to be set before the subscriber is called. 203 | created := make(map[types.Hash256]bool) 204 | ephemeral := make(map[types.Hash256]bool) 205 | for _, txn := range cru.Block.Transactions { 206 | for i := range txn.SiacoinOutputs { 207 | created[types.Hash256(txn.SiacoinOutputID(i))] = true 208 | } 209 | for _, input := range txn.SiacoinInputs { 210 | ephemeral[types.Hash256(input.ParentID)] = created[types.Hash256(input.ParentID)] 211 | } 212 | for i := range txn.SiafundOutputs { 213 | created[types.Hash256(txn.SiafundOutputID(i))] = true 214 | } 215 | for _, input := range txn.SiafundInputs { 216 | ephemeral[types.Hash256(input.ParentID)] = created[types.Hash256(input.ParentID)] 217 | } 218 | } 219 | 220 | for _, sced := range cru.SiacoinElementDiffs() { 221 | sce := sced.SiacoinElement 222 | if (sced.Created && sced.Spent) || sce.SiacoinOutput.Value.IsZero() { 223 | continue 224 | } else if relevant, err := tx.AddressRelevant(sce.SiacoinOutput.Address); err != nil { 225 | panic(err) 226 | } else if !relevant { 227 | continue 228 | } 229 | if sced.Spent { 230 | // re-add any spent siacoin elements 231 | reverted.UnspentSiacoinElements = append(reverted.UnspentSiacoinElements, sce) 232 | } else { 233 | // delete any created siacoin elements 234 | reverted.DeletedSiacoinElements = append(reverted.DeletedSiacoinElements, sce) 235 | } 236 | } 237 | for _, sfed := range cru.SiafundElementDiffs() { 238 | sfe := sfed.SiafundElement 239 | if (sfed.Created && sfed.Spent) || sfe.SiafundOutput.Value == 0 { 240 | continue 241 | } else if relevant, err := tx.AddressRelevant(sfe.SiafundOutput.Address); err != nil { 242 | panic(err) 243 | } else if !relevant { 244 | continue 245 | } 246 | if sfed.Spent { 247 | reverted.UnspentSiafundElements = append(reverted.UnspentSiafundElements, sfe) 248 | } else { 249 | reverted.DeletedSiafundElements = append(reverted.DeletedSiafundElements, sfe) 250 | } 251 | } 252 | 253 | if err := tx.RevertIndex(revertedIndex, reverted); err != nil { 254 | return fmt.Errorf("failed to revert index: %w", err) 255 | } 256 | return updateStateElements(tx, cru, indexMode) 257 | } 258 | 259 | // UpdateChainState atomically updates the state of a store with a set of 260 | // updates from the chain manager. 261 | func UpdateChainState(tx UpdateTx, reverted []chain.RevertUpdate, applied []chain.ApplyUpdate, indexMode IndexMode, log *zap.Logger) error { 262 | for _, cru := range reverted { 263 | revertedIndex := types.ChainIndex{ 264 | ID: cru.Block.ID(), 265 | Height: cru.State.Index.Height + 1, 266 | } 267 | if err := revertChainUpdate(tx, cru, revertedIndex, indexMode); err != nil { 268 | return fmt.Errorf("failed to revert chain update %q: %w", revertedIndex, err) 269 | } 270 | log.Debug("reverted chain update", zap.Stringer("blockID", revertedIndex.ID), zap.Uint64("height", revertedIndex.Height)) 271 | } 272 | 273 | for _, cau := range applied { 274 | // apply the chain update 275 | if err := applyChainUpdate(tx, cau, indexMode); err != nil { 276 | return fmt.Errorf("failed to apply chain update %q: %w", cau.State.Index, err) 277 | } 278 | log.Debug("applied chain update", zap.Stringer("blockID", cau.State.Index.ID), zap.Uint64("height", cau.State.Index.Height)) 279 | } 280 | return nil 281 | } 282 | --------------------------------------------------------------------------------