diff --git a/.gemini/config.yaml b/.gemini/config.yaml index 307f783561..0494774758 100644 --- a/.gemini/config.yaml +++ b/.gemini/config.yaml @@ -33,4 +33,4 @@ code_review: # List of glob patterns to ignore (files and directories). # Type: array of string, default: []. -ignore_patterns: [] +ignore_patterns: ["deprecated.go"] diff --git a/.gemini/styleguide.md b/.gemini/styleguide.md index f0d52a1e1e..83bf86ec2a 100644 --- a/.gemini/styleguide.md +++ b/.gemini/styleguide.md @@ -1,4 +1,4 @@ -# LND Style Guide +# Btcwallet Style Guide ## Code Documentation and Commenting @@ -9,7 +9,7 @@ - Unit tests must always use the `require` library. Either table driven unit tests or tests using the `rapid` library are preferred. - The line length MUST NOT exceed 80 characters, this is very important. - You must count the Golang indentation (tabulator character) as 8 spaces when + You must count the Golang indentation (tabulator character) as 4 spaces when determining the line length. Use creative approaches or the wrapping rules specified below to make sure the line length isn't exceeded. - Every function must be commented with its purpose and assumptions. @@ -151,7 +151,7 @@ if amt < 546 { ### 80 character line length - Wrap columns at 80 characters. -- Tabs are 8 spaces. +- Tabs are 4 spaces. **WRONG** ```go diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7591492088..a67fed64e1 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -30,6 +30,8 @@ env: BITCOIND_VERSION: '22.0' BITCOIND_IMAGE: 'lightninglabs/bitcoin-core' + BTCD_VERSION_LATEST: v0.25.0 + jobs: ######################## # Format, compileation and lint check @@ -63,6 +65,15 @@ jobs: - name: Check RPC format run: make rpc-check + - name: Check generated SQL code is up-to-date + run: make sqlc-check + + - name: Check SQL formatting + run: make sql-format-check + + - name: Check SQL linting + run: make sql-lint-check + - name: compile code run: go install -v ./... @@ -112,10 +123,22 @@ jobs: steps: - name: extract bitcoind from docker image run: |- - docker pull ${{ env.BITCOIND_IMAGE }}:${{ env.BITCOIND_VERSION }} - CONTAINER_ID=$(docker create ${{ env.BITCOIND_IMAGE }}:${{ env.BITCOIND_VERSION }}) - sudo docker cp $CONTAINER_ID:/opt/bitcoin-${{ env.BITCOIND_VERSION }}/bin/bitcoind /usr/local/bin/bitcoind + IMAGE=${{ env.BITCOIND_IMAGE }}:${{ env.BITCOIND_VERSION }} + docker pull $IMAGE + + BIN_PATH=$(docker run --rm --entrypoint sh $IMAGE -c ' + command -v bitcoind || + find /opt /usr -type f -name bitcoind 2>/dev/null | head -n 1 + ') + if [ -z "$BIN_PATH" ]; then + echo "could not locate bitcoind in $IMAGE" >&2 + exit 1 + fi + + CONTAINER_ID=$(docker create $IMAGE) + sudo docker cp $CONTAINER_ID:$BIN_PATH /usr/local/bin/bitcoind docker rm $CONTAINER_ID + bitcoind --version - name: git checkout uses: actions/checkout@v5 @@ -123,17 +146,6 @@ jobs: - name: Clean up runner space uses: ./.github/actions/cleanup-space - - name: go cache - uses: actions/cache@v4 - with: - path: /home/runner/work/go - key: btcwallet-${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ github.job }}-${{ hashFiles('**/go.sum') }} - restore-keys: | - btcwallet-${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ github.job }}-${{ hashFiles('**/go.sum') }} - btcwallet-${{ runner.os }}-go-${{ env.GO_VERSION }}-${{ github.job }}- - btcwallet-${{ runner.os }}-go-${{ env.GO_VERSION }}- - btcwallet-${{ runner.os }}-go- - - name: setup go ${{ env.GO_VERSION }} uses: actions/setup-go@v5 with: @@ -143,9 +155,225 @@ jobs: run: make ${{ matrix.unit_type }} - name: Send coverage - uses: shogo82148/actions-goveralls@v1 + uses: coverallsapp/github-action@v2 if: matrix.unit_type == 'unit-cover' continue-on-error: true with: - path-to-profile: coverage.txt + file: coverage.txt + flag-name: unit + format: golang parallel: true + + ######################## + # run integration tests (SQLite + Postgres) + ######################## + itest-db: + name: ${{ matrix.db }} itest (${{ matrix.race && 'race' || 'cover' }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + db: [sqlite, postgres] + race: [false, true] + steps: + - name: git checkout + uses: actions/checkout@v5 + + - name: Clean up runner space + uses: ./.github/actions/cleanup-space + + - name: setup go ${{ env.GO_VERSION }} + uses: actions/setup-go@v5 + with: + go-version: '${{ env.GO_VERSION }}' + + - name: run ${{ matrix.db }} itest-db (coverage) + if: ${{ !matrix.race }} + run: make itest-db db=${{ matrix.db }} cover=1 verbose=1 + + - name: run ${{ matrix.db }} itest-db-race + if: matrix.race + run: make itest-db-race db=${{ matrix.db }} verbose=1 + + - name: Upload coverage to Coveralls + uses: coverallsapp/github-action@v2 + if: ${{ !matrix.race }} + continue-on-error: true + with: + file: coverage-itest-${{ matrix.db }}.txt + flag-name: itest-db-${{ matrix.db }} + format: golang + parallel: true + + ######################## + # Run bwtest integration tests against each supported chain backend. + # + # These jobs currently use kvdb only. Database expansion is planned via a + # separate matrix in a future change. + ######################## + itest-btcd: + name: itest btcd + runs-on: ubuntu-latest + steps: + - name: git checkout + uses: actions/checkout@v5 + + - name: Clean up runner space + uses: ./.github/actions/cleanup-space + + - name: setup go ${{ env.GO_VERSION }} + uses: actions/setup-go@v5 + with: + go-version: '${{ env.GO_VERSION }}' + + - name: add go bin to PATH + run: echo "$(go env GOPATH)/bin" >> $GITHUB_PATH + + - name: install btcd ${{ env.BTCD_VERSION_LATEST }} + run: go install -v github.com/btcsuite/btcd@${{ env.BTCD_VERSION_LATEST }} + + - name: check btcd version + run: btcd --version + + # The btcd backend job runs btcd for both the shared miner and the chain + # backend under test. + - name: run itest (btcd, kvdb) + run: make itest chain=btcd db=kvdb + + - name: upload itest logs + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: itest-logs-btcd + path: itest/test-logs + retention-days: 5 + + ######################## + # Run bwtest integration tests with the neutrino backend. + # + # Job flow: + # - Install btcd `${{ env.BTCD_VERSION_LATEST }}`. + # - Use btcd as the shared miner for the suite. + # - Run `make itest chain=neutrino db=kvdb` so wallets use the in-process + # neutrino backend while blocks/peers come from btcd. + ######################## + itest-neutrino: + name: itest neutrino + runs-on: ubuntu-latest + steps: + - name: git checkout + uses: actions/checkout@v5 + + - name: Clean up runner space + uses: ./.github/actions/cleanup-space + + - name: setup go ${{ env.GO_VERSION }} + uses: actions/setup-go@v5 + with: + go-version: '${{ env.GO_VERSION }}' + + - name: add go bin to PATH + run: echo "$(go env GOPATH)/bin" >> $GITHUB_PATH + + - name: install btcd ${{ env.BTCD_VERSION_LATEST }} + run: go install -v github.com/btcsuite/btcd@${{ env.BTCD_VERSION_LATEST }} + + - name: check btcd version + run: btcd --version + + # Neutrino runs in-process, but it still relies on the shared btcd miner + # for chain data and peer connectivity. + - name: run itest (neutrino, kvdb) + run: make itest chain=neutrino db=kvdb + + - name: upload itest logs + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: itest-logs-neutrino + path: itest/test-logs + retention-days: 5 + + ######################## + # Run bwtest integration tests with the bitcoind backend. + # + # Job flow: + # - Install btcd `${{ env.BTCD_VERSION_LATEST }}` as the shared miner. + # - Run a matrix over bitcoind versions `30` (latest) and `28` (older). + # - Extract each matrix binary from the docker image and run + # `make itest chain=bitcoind db=kvdb`. + ######################## + itest-bitcoind: + name: itest bitcoind (v${{ matrix.bitcoind_version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + bitcoind_version: ['30', '28'] + steps: + - name: git checkout + uses: actions/checkout@v5 + + - name: Clean up runner space + uses: ./.github/actions/cleanup-space + + - name: setup go ${{ env.GO_VERSION }} + uses: actions/setup-go@v5 + with: + go-version: '${{ env.GO_VERSION }}' + + - name: add go bin to PATH + run: echo "$(go env GOPATH)/bin" >> $GITHUB_PATH + + - name: install btcd ${{ env.BTCD_VERSION_LATEST }} + run: go install -v github.com/btcsuite/btcd@${{ env.BTCD_VERSION_LATEST }} + + - name: check btcd version + run: btcd --version + + # For bitcoind backend tests, btcd remains the shared miner while the + # matrix covers both bitcoind v30 (latest) and v28 (older). + - name: extract bitcoind from docker image (${{ matrix.bitcoind_version }}) + run: |- + IMAGE=${{ env.BITCOIND_IMAGE }}:${{ matrix.bitcoind_version }} + docker pull $IMAGE + + BIN_PATH=$(docker run --rm --entrypoint sh $IMAGE -c ' + command -v bitcoind || + find /opt /usr -type f -name bitcoind 2>/dev/null | head -n 1 + ') + if [ -z "$BIN_PATH" ]; then + echo "could not locate bitcoind in $IMAGE" >&2 + exit 1 + fi + + CONTAINER_ID=$(docker create $IMAGE) + sudo docker cp $CONTAINER_ID:$BIN_PATH /usr/local/bin/bitcoind + docker rm $CONTAINER_ID + bitcoind --version + + - name: run itest (bitcoind, kvdb) + run: make itest chain=bitcoind db=kvdb + + - name: upload itest logs + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: itest-logs-bitcoind-${{ matrix.bitcoind_version }} + path: itest/test-logs + retention-days: 5 + + ######################## + # Complete parallel coverage uploads + ######################## + finish: + name: Finish coverage upload + if: ${{ !cancelled() }} + needs: [unit-test, itest-db] + runs-on: ubuntu-latest + steps: + - name: Finish parallel Coveralls upload + uses: coverallsapp/github-action@v2 + continue-on-error: true + with: + parallel-finished: true diff --git a/.gitignore b/.gitignore index 8dce5946b3..1ab06d6ed7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,20 @@ btcwallet vendor .idea coverage.txt +coverage-itest-postgres.txt +coverage-itest-sqlite.txt *.swp .vscode .DS_Store .aider* +/.worktrees/ +coverage.out +*.prof +*.test +*cpu.out + +# Integration test logs. +itest/test-logs/ + +# Backwards compatibility for older log dir. +itest/.minerlogs/ diff --git a/.golangci.yml b/.golangci.yml index 4a4324f61f..216641d486 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -10,6 +10,9 @@ run: linters: default: all disable: + # TODO(yy): Re-enable this linter once the refactoring series is done. + - ireturn + # Global variables are used in many places throughout the code base. - gochecknoglobals @@ -48,7 +51,12 @@ linters: funlen: # Checks the number of lines in a function. # If lower than 0, disable the check. - lines: 100 + # Increased from 100 to 200 to allow more verbose parameters while still + # keeping low complexity. e.g.: + # func (w *PostgresWalletDB) CreateDerivedAccount(ctx context.Context, + # params CreateDerivedAccountParams) (*AccountInfo, error) { + lines: 200 + # Checks the number of statements in a function. statements: 50 @@ -80,6 +88,19 @@ linters: multi-func: true multi-if: true + wrapcheck: + ignore-sig-regexps: + # Allow returning .Err() from context.Context without wrapping it. + - context\.Context.*\.Err\(\) + + gomoddirectives: + replace-local: true + replace-allow-list: + # This package will be downgrade to internal so we will import it + # directly here. + - github.com/btcsuite/btcwallet/wtxmgr + + # Defines a set of rules to ignore issues. # It does not skip the analysis, and so does not ignore "typecheck" errors. exclusions: @@ -101,6 +122,7 @@ linters: paths: - rpc/legacyrpc/ - wallet/deprecated.go + - wallet/deprecated_test.go rules: # Exclude gosec from running for tests so that tests with weak randomness @@ -116,6 +138,13 @@ linters: # Allow returning unwrapped errors in tests. - wrapcheck + # The split backend packages intentionally forward many calls into the + # shared db helpers. Keep wrapcheck enabled elsewhere while the package + # split settles. + - path: wallet/internal/db/(pg|sqlite|kvdb)/.*\.go + linters: + - wrapcheck + - path: mock* linters: - revive @@ -134,6 +163,25 @@ linters: linters: - forbidigo + # Allow deprecated entrypoints to be exercised from the kvdb adapter + # tests during the tx-writer migration. + - path: wallet/internal/db/kvdb/.*_test\.go + linters: + - staticcheck + + # E2E integration tests run a long-lived backend harness and execute test + # cases in registration order so the suite can stop on first failure. + - path: itest/.*_test\.go + linters: + - paralleltest + + # DB integration tests often share a backend fixture across subtests, and + # forcing parallel subtests makes those ordering assumptions unsafe. + - path: wallet/internal/db/itest/.*_test\.go + linters: + - paralleltest + - tparallel + issues: # Show only new issues created after git revision `REV`. # Default: "" diff --git a/.sqlfluff b/.sqlfluff new file mode 100644 index 0000000000..3ae05159ec --- /dev/null +++ b/.sqlfluff @@ -0,0 +1,41 @@ +# SQLFluff configuration +# Source: https://docs.sqlfluff.com/en/stable/configuration/setting_configuration.html + +[sqlfluff] +# Supported dialects: postgres, sqlite +# We specify dialect via CLI (-d flag) to handle both postgres and sqlite +# sql_file_exts = .sql +# exclude_rules = None + +# Standard max line length +max_line_length = 120 + +# Use all available CPU cores for better performance +processes = 0 + +[sqlfluff:indentation] +# Use spaces for indentation +indent_unit = space +tab_space_size = 4 +indented_joins = False +indented_using_on = True + +[sqlfluff:rules:capitalisation.keywords] +# Keywords should be uppercase (e.g., SELECT, FROM, WHERE) +capitalisation_policy = upper + +[sqlfluff:rules:capitalisation.identifiers] +# Table and column names should be lowercase +extended_capitalisation_policy = lower + +[sqlfluff:rules:capitalisation.functions] +# Function names should be lowercase +extended_capitalisation_policy = lower + +[sqlfluff:rules:capitalisation.types] +# Data types should be uppercase (e.g., INTEGER, BYTEA, BLOB) +extended_capitalisation_policy = upper + +[sqlfluff:rules:convention.not_equal] +# Prefer != over <> for not equal +preferred_not_equal_style = c_style diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..1fdf90145f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,160 @@ +# AGENTS.md + +This file is for coding agents working in `btcwallet`. + +## Scope and Priority + +- Follow this file first for repo-specific workflow and style. +- Then follow `docs/developer/` for deeper rationale. +- If guidance conflicts, prefer the stricter rule and match nearby code. + +## Repo Workflow + +- Keep the main checkout at the repo root. +- Place auxiliary worktrees under `/.worktrees/`. +- Use short issue- or task-oriented worktree names. +- Do not create sibling worktrees outside the repo root unless asked. +- Remove finished worktrees with `git worktree remove `. +- Clean stale worktree metadata with `git worktree prune`. + +## Tooling and Environment + +- The authoritative Go version is `1.24.6`. +- Sources: `go.mod`, `.golangci.yml`, and `.github/workflows/main.yml`. +- Some older docs mention older Go versions; ignore them. +- Many maintenance targets use Docker-backed tooling. +- `make fmt`, `make lint*`, `make sqlc`, and `make protolint` use Docker. +- PostgreSQL DB integration tests also require Docker via testcontainers. +- No Cursor rules were found in `.cursor/rules/` or `.cursorrules`. +- No Copilot instructions were found in `.github/copilot-instructions.md`. +- There is an additional style summary in `.gemini/styleguide.md`. + +## Primary Build, Format, and Codegen Commands + +- Build everything: `make build` +- Install binaries: `make install` +- CI compile check: `go install -v ./...` +- Default `make` target is `make build`. +- `make install` installs `btcwallet`, `cmd/dropwtxmgr`, and `cmd/sweepaccount`. +- Go format/imports: `make fmt`; verify with `make fmt-check` +- Lint: `make lint-config-check`, `make lint-check`, `make lint`; optional `workers=4` +- Proto: `make rpc-format`, `make rpc`, `make rpc-check`, `make protolint` +- SQL: `make sql-parse`, `make sql-format`, `make sql-format-check`, `make sql-lint`, `make sql-lint-check`, `make sqlc`, `make sqlc-check`, `make sql` +- Modules and config: `make tidy-module`, `make tidy-module-check`, `make sample-conf-check` + +## Command Gotchas + +- Several `*-check` targets modify files before checking git cleanliness. +- `make fmt-check` runs `make fmt` first. +- `make rpc-check` runs code generation first. +- `make sqlc-check` runs SQL codegen first. +- `make sql-format-check` formats SQL first. +- `make tidy-module-check` runs `go mod tidy` first. +- Do not run those blindly in a dirty tree unless you expect edits. + +## Unit Test Commands + +- Run all unit tests: `make unit` +- Run one package: `make unit pkg=wallet` +- Run one specific test: `make unit pkg=wallet case=TestBuildTxDetail` +- Common flags: `verbose=1`, `nocache=1`, `timeout=5m` +- Run unit tests with race detector: `make unit-race` +- Run targeted race test: `make unit-race pkg=wallet case=TestBuildTxDetail` +- Run unit coverage: `make unit-cover` +- Run targeted coverage: `make unit-cover pkg=wallet` +- Run benchmarks for one package: `make unit-bench pkg=wallet` +- Include alloc stats: `make unit-bench pkg=wallet benchmem=1` + +## Integration Test Commands + +- DB itests: `make itest-db db=sqlite` or `make itest-db db=postgres` +- Single DB itest: `make itest-db db=postgres case=TestWhatever verbose=1` +- DB coverage/race: `make itest-db db=postgres cover=1 verbose=1`, `make itest-db-race db=sqlite verbose=1` +- The DB integration suite lives in `wallet/internal/db/itest`. +- E2E default: `make itest` +- E2E backends: `make itest chain=btcd db=kvdb`, `make itest chain=neutrino db=kvdb`, `make itest chain=bitcoind db=kvdb` +- E2E case filter: `make itest icase=manager` or `make itest chain=btcd db=kvdb icase=manager` +- E2E logs go to `itest/test-logs/`; case names must follow `component action` and must not use `_`. + +## Verification Strategy + +- Run the narrowest relevant test first. +- Before handing off a substantial change, run at least package-level tests. +- For SQL, proto, module, or config changes, run the matching generation/check target. +- For DB-layer changes, prefer `itest-db` in addition to unit tests. +- For backend flow or RPC changes, consider `make itest` coverage. +- If a change claims performance improvement, add or run a benchmark. +- CI covers formatting, imports, modules, proto, SQL, lint, unit, DB, and e2e. + +## Go Formatting and Imports + +- Follow `Effective Go` and the repo docs in `docs/developer/`. +- Let `make fmt` manage imports through `gosimports` and `gofmt`. +- Do not manually fight import grouping; accept formatter output. +- Go files use tab indentation. +- Markdown files use LF and wrap to 80 characters. +- Keep lines near 80 columns on a best-effort basis. +- The style docs mention treating tabs as width 8 for visual wrapping. +- `.editorconfig` and linter settings use width 4; keep lines conservative. +- Formatting excludes generated `*.pb.go` files. + +## Code Layout and Naming + +- Break functions into logical stanzas separated by blank lines. +- Add comments where intent is not obvious; explain why, not the mechanics. +- Every function should have a purpose comment; comments must start with the function name. +- Exported functions need caller-oriented comments, not just maintainer notes. +- Wrap long function calls one argument per line with `)` on its own line. +- If a function declaration spans multiple lines, start the body after a blank line. +- Avoid generic package names like `utils`, `common`, or `helpers`; use `internal` for non-public code. +- Prefer domain-focused package boundaries, avoid circular dependencies, and accept interfaces while returning concrete structs. +- Match existing names in the surrounding package before inventing new terms. + +## Types, Errors, and Concurrency + +- Put `context.Context` first for blocking or long-running operations. +- Wrap dependency errors with context using `%w`. +- Prefer sentinel errors for important conditions and check with `errors.Is`. +- Define normal non-exceptional cases out of the error path when practical. +- Prefer communicating over shared memory. +- Never start a goroutine without a clear shutdown path. +- Do not access maps concurrently without synchronization. +- Treat slices as shared mutable state unless ownership is explicit. +- Pass sync primitives by pointer, not by value. + +## Logging Guidelines + +- Supported levels are `trace`, `debug`, `info`, `warn`, `error`, `critical`. +- Use `error` for unexpected internal failures. +- Expected external failures usually belong at `info`, `debug`, or `warn`. +- Much of the repo still uses legacy `log.Tracef/Debugf/...` patterns. +- In files that already use legacy logging, preserve the local style. +- If adding structured logging, keep the message static and put data in attributes. +- Use `slog.Attr` or helpers like `btclog.Fmt` for structured log fields. +- Log and error formatting are exceptions to the usual multiline call wrapping. + +## Test Style Guidelines + +- New non-trivial behavior and bug fixes should come with regression tests. +- Cover both positive paths and negative or error paths. +- Prefer `require` over `assert` for most checks. +- Structure tests as Arrange, Act, Assert with blank lines between sections. +- Use table-driven tests only when setup shape is identical across cases. +- If setup differs across cases, prefer separate standalone tests and keep case structs data-only. +- Use descriptive flat test names and `t.Parallel()` where safe. +- Tests commonly use fast scrypt parameters; do not remove that optimization. + +## SQL, Proto, Modules, and PRs + +- SQL is formatted and linted with SQLFluff. +- SQL keywords and types should be uppercase; identifiers and function names should be lowercase. +- Prefer `!=` over `<>`; SQL indentation uses 4 spaces. +- Protos are formatted with `clang-format` through `make rpc-format`. +- Proto messages use UpperCamelCase; filenames use lower_snake_case. +- Proto imports should be sorted, package names lowercase, and services/RPCs commented. +- The repo contains multiple Go submodules; avoid ad hoc local `replace` directives. +- Favor small, reviewable commits. +- Commit subjects typically look like `subsystem: short description`. +- Use present tense, keep the subject near 50 chars, and wrap bodies near 72. +- PRs should include clear test steps and cover positive and negative cases. +- Insubstantial typo-only changes are discouraged by the contribution guide. diff --git a/Makefile b/Makefile index def43ca079..4c03045b53 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,18 @@ GOINSTALL := GO111MODULE=on go install -v GOFILES = $(shell find . -type f -name '*.go' -not -name "*.pb.go") + +# SQL directories. +SQL_DIR := wallet/internal/sql + +# SQL file paths. +SQL_POSTGRES_DIR := $(SQL_DIR)/pg +SQL_POSTGRES_MIGRATIONS := $(SQL_POSTGRES_DIR)/migrations +SQL_POSTGRES_QUERIES := $(SQL_POSTGRES_DIR)/queries +SQL_SQLITE_DIR := $(SQL_DIR)/sqlite +SQL_SQLITE_MIGRATIONS := $(SQL_SQLITE_DIR)/migrations +SQL_SQLITE_QUERIES := $(SQL_SQLITE_DIR)/queries + RM := rm -f CP := cp MAKE := make @@ -14,15 +26,53 @@ XARGS := xargs -L 1 include make/testing_flags.mk # Linting uses a lot of memory, so keep it under control by limiting the number -# of workers if requested. -ifneq ($(workers),) +# of workers. Override with `workers=` when needed. +workers ?= 4 LINT_WORKERS = --concurrency=$(workers) -endif + +GOLANGCI_LINT = golangci-lint run -v --config .golangci.yml $(LINT_WORKERS) +LINT_DB_SQLITE_PKGS = ./wallet/internal/db/sqlite ./wallet/internal/db/itest +LINT_DB_POSTGRES_PKGS = ./wallet/internal/db/pg ./wallet/internal/db/itest + +define lint_targets + @$(call print, "Linting source.") + $(DOCKER_TOOLS) $(GOLANGCI_LINT) $(LINT_FIX) + @$(call print, "Linting integration tests.") + $(DOCKER_TOOLS) $(GOLANGCI_LINT) $(LINT_FIX) \ + --build-tags="itest $(DEV_TAGS) nolog" ./itest + @$(call print, "Linting SQLite DB integration tests.") + $(DOCKER_TOOLS) $(GOLANGCI_LINT) $(LINT_FIX) \ + --build-tags="itest $(DEV_TAGS) $(LOG_TAGS)" \ + $(LINT_DB_SQLITE_PKGS) + @$(call print, "Linting PostgreSQL DB integration tests.") + $(DOCKER_TOOLS) $(GOLANGCI_LINT) $(LINT_FIX) \ + --build-tags="itest $(DEV_TAGS) $(LOG_TAGS) test_db_postgres" \ + $(LINT_DB_POSTGRES_PKGS) +endef + +# Detect if we're in a git worktree. Use git rev-parse --git-common-dir to get +# the path to the main git directory for the linter's diff processor to work +# correctly with the new-from-rev setting. +GIT_COMMON_DIR := $(shell \ + common_dir="$$(git rev-parse --git-common-dir 2>/dev/null)"; \ + if [ "$$common_dir" != ".git" ] && [ -n "$$common_dir" ]; then \ + echo "$$common_dir"; \ + fi) +GIT_VOLUME := $(if $(GIT_COMMON_DIR),-v "$(GIT_COMMON_DIR):$(GIT_COMMON_DIR):ro",) DOCKER_TOOLS = docker run \ --rm \ -v $(shell bash -c "mkdir -p /tmp/go-build-cache; echo /tmp/go-build-cache"):/root/.cache/go-build \ - -v $$(pwd):/build btcwallet-tools + -v $$(pwd):/build \ + $(GIT_VOLUME) \ + btcwallet-tools + +SQLFLUFF = docker run \ + --rm \ + --user $$(id -u):$$(id -g) \ + -v $$(pwd):/sql \ + -w /sql \ + sqlfluff/sqlfluff GREEN := "\\033[0;32m" NC := "\\033[0m" @@ -84,6 +134,44 @@ unit-bench: @$(call print, "Running benchmark tests.") $(UNIT_BENCH) +#? itest-db: Run integration tests for wallet database +itest-db: + @if [ -z "$(IT_DB_LABEL)" ]; then \ + echo "Unknown integration test database '$(db)'. Use db=sqlite or db=postgres." ; \ + exit 1 ; \ + fi + @$(call print, "Running $(IT_DB_LABEL) integration tests.") + $(ITEST_DB) + @if [ -n "$(ITEST_DB_COVERPROFILE)" ]; then \ + echo "Filtering coverage report."; \ + ./scripts/filter_coverage.sh $(IT_DB_TYPE); \ + fi + +#? itest-db-race: Run integration tests for wallet database with race detector +itest-db-race: + @if [ -z "$(IT_DB_LABEL)" ]; then \ + echo "Unknown integration test database '$(db)'. Use db=sqlite or db=postgres." ; \ + exit 1 ; \ + fi + @$(call print, "Running $(IT_DB_LABEL) integration tests (race).") + env CGO_ENABLED=1 GORACE="history_size=7 halt_on_errors=1" $(ITEST_DB_RACE) + +#? itest: Run integration tests +#? itest (vars): chain=btcd|bitcoind|neutrino backend=btcd|bitcoind|neutrino db=kvdb|sqlite|postgres +#? itest (vars): icase= (filter itest cases) +#? itest (vars): timeout= verbose=1 nocache=1 +#? itest (ex): make itest icase=manager +#? itest (ex): make itest chain=btcd db=kvdb +itest: + @$(call print, "Running integration tests.") + @$(GOTEST) -v ./itest \ + -tags="itest $(DEV_TAGS) nolog" \ + $(if $(icase),-test.run="TestBtcWallet$$|$(icase)",) \ + $(filter-out -test.run=%,$(TEST_FLAGS)) \ + -args \ + -chain="$(if $(backend),$(backend),$(if $(chain),$(chain),btcd))" \ + -db="$(if $(db),$(db),kvdb)" + # ========= # UTILITIES # ========= @@ -108,17 +196,16 @@ rpc-format: #? lint-config-check: Verify golangci-lint configuration lint-config-check: docker-tools @$(call print, "Verifying golangci-lint configuration.") - $(DOCKER_TOOLS) golangci-lint config verify -v + $(DOCKER_TOOLS) golangci-lint config verify -v --config .golangci.yml #? lint: Lint source and check errors lint-check: lint-config-check - @$(call print, "Linting source.") - $(DOCKER_TOOLS) golangci-lint run -v $(LINT_WORKERS) + $(call lint_targets) #? lint: Lint source and fix +lint: LINT_FIX = --fix lint: lint-config-check - @$(call print, "Linting source.") - $(DOCKER_TOOLS) golangci-lint run -v --fix $(LINT_WORKERS) + $(call lint_targets) #? docker-tools: Build tools docker image docker-tools: @@ -138,7 +225,7 @@ rpc-check: rpc #? protolint: Lint proto files using protolint protolint: @$(call print, "Linting proto files.") - docker run --rm --volume "$$(pwd):/workspace" --workdir /workspace yoheimuta/protolint lint rpc/ + $(DOCKER_TOOLS) protolint lint -config_dir_path=. rpc/ #? sample-conf-check: Make sure default values in the sample-btcwallet.conf file are set correctly sample-conf-check: install @@ -159,6 +246,79 @@ tidy-module: tidy-module-check: tidy-module if test -n "$$(git status --porcelain)"; then echo "modules not updated, please run `make tidy-module` again!"; git status; exit 1; fi +#? sql-parse: Ensures SQL files are syntactically valid +sql-parse: + @$(call print, "Validating SQL files (postgres migrations).") + $(SQLFLUFF) parse --config /sql/.sqlfluff --dialect postgres $(SQL_POSTGRES_MIGRATIONS) --format none + @$(call print, "Validating SQL files (postgres queries).") + $(SQLFLUFF) parse --config /sql/.sqlfluff --dialect postgres $(SQL_POSTGRES_QUERIES) --format none + @$(call print, "Validating SQL files (sqlite migrations).") + $(SQLFLUFF) parse --config /sql/.sqlfluff --dialect sqlite $(SQL_SQLITE_MIGRATIONS) --format none + @$(call print, "Validating SQL files (sqlite queries).") + $(SQLFLUFF) parse --config /sql/.sqlfluff --dialect sqlite $(SQL_SQLITE_QUERIES) --format none + +#? sqlc: Generate Go code from SQL queries and migrations +sqlc: sql-parse docker-tools + @$(call print, "Generating sql models and queries in Go") + $(DOCKER_TOOLS) sqlc generate -f sqlc.yaml + +#? sqlc-check: Verify generated Go SQL queries and migrations are up-to-date +sqlc-check: sqlc + @$(call print, "Verifying sql code generation.") + if test -n "$$(git status --porcelain '*.go')"; then echo "SQL models not properly generated!"; git status --porcelain '*.go'; exit 1; fi + +#? sql-format: Format SQL migration and query files (like 'make fmt') +sql-format: + @$(call print, "Formatting SQL files (postgres migrations).") + $(SQLFLUFF) format --config /sql/.sqlfluff --dialect postgres $(SQL_POSTGRES_MIGRATIONS) + @$(call print, "Formatting SQL files (postgres queries).") + $(SQLFLUFF) format --config /sql/.sqlfluff --dialect postgres $(SQL_POSTGRES_QUERIES) + @$(call print, "Formatting SQL files (sqlite migrations).") + $(SQLFLUFF) format --config /sql/.sqlfluff --dialect sqlite $(SQL_SQLITE_MIGRATIONS) + @$(call print, "Formatting SQL files (sqlite queries).") + $(SQLFLUFF) format --config /sql/.sqlfluff --dialect sqlite $(SQL_SQLITE_QUERIES) + +#? sql-check: Verify SQL migration and query files are formatted correctly (like 'make fmt-check') +sql-format-check: sql-format + @$(call print, "Checking SQL formatting.") + if test -n "$$(git status --porcelain '$(SQL_DIR)/**/*.sql')"; then echo "SQL files not formatted correctly, please run 'make sql-format' again!"; git status; git diff; exit 1; fi + +#? sql-lint: Lint SQL migration and query files and fix issues (like 'make lint') +sql-lint: + @$(call print, "Linting SQL files (postgres migrations).") + $(SQLFLUFF) fix --config /sql/.sqlfluff --dialect postgres $(SQL_POSTGRES_MIGRATIONS) + @$(call print, "Linting SQL files (postgres queries).") + $(SQLFLUFF) fix --config /sql/.sqlfluff --dialect postgres $(SQL_POSTGRES_QUERIES) + @$(call print, "Linting SQL files (sqlite migrations).") + $(SQLFLUFF) fix --config /sql/.sqlfluff --dialect sqlite $(SQL_SQLITE_MIGRATIONS) + @$(call print, "Linting SQL files (sqlite queries).") + $(SQLFLUFF) fix --config /sql/.sqlfluff --dialect sqlite $(SQL_SQLITE_QUERIES) + +#? sql-lint-check: Lint SQL files and report errors (like 'make lint-check') +sql-lint-check: + @$(call print, "Linting SQL files (postgres migrations).") + $(SQLFLUFF) lint --config /sql/.sqlfluff --dialect postgres $(SQL_POSTGRES_MIGRATIONS) + @$(call print, "Linting SQL files (postgres queries).") + $(SQLFLUFF) lint --config /sql/.sqlfluff --dialect postgres $(SQL_POSTGRES_QUERIES) + @$(call print, "Linting SQL files (sqlite migrations).") + $(SQLFLUFF) lint --config /sql/.sqlfluff --dialect sqlite $(SQL_SQLITE_MIGRATIONS) + @$(call print, "Linting SQL files (sqlite queries).") + $(SQLFLUFF) lint --config /sql/.sqlfluff --dialect sqlite $(SQL_SQLITE_QUERIES) + +#? sql: Lint, verify, format, and regenerate SQL code for local development +# First try auto-fixes. If that still fails, rerun lint in check mode so the +# remaining unfixable violations are printed before aborting the workflow. +# On success, verify the final SQL is clean before formatting and regeneration. +sql: + @if ! $(MAKE) --no-print-directory --silent sql-lint; then \ + $(MAKE) --no-print-directory --silent sql-lint-check; \ + exit 1; \ + fi + @$(MAKE) --no-print-directory --silent sql-lint-check + @$(MAKE) --no-print-directory --silent sql-format + @$(MAKE) --no-print-directory --silent sqlc + + .PHONY: all \ default \ build \ @@ -168,10 +328,21 @@ tidy-module-check: tidy-module unit-race \ unit-debug \ unit-bench \ + itest \ + itest-db \ + itest-db-race \ fmt \ fmt-check \ tidy-module \ tidy-module-check \ + sql-parse \ + sql \ + sqlc \ + sqlc-check \ + sql-format \ + sql-lint \ + sql-lint-check \ + sql-format-check \ rpc-format \ lint \ lint-config-check \ diff --git a/README.md b/README.md index b7cb2c5c60..ff6dc97811 100644 --- a/README.md +++ b/README.md @@ -17,15 +17,13 @@ disk. btcwallet uses the HD path for all derived addresses, as described by [BIP0044](https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki). -Due to the sensitive nature of public data in a BIP0032 wallet, -btcwallet provides the option of encrypting not just private keys, but -public data as well. This is intended to thwart privacy risks where a -wallet file is compromised without exposing all current and future -addresses (public keys) managed by the wallet. While access to this -information would not allow an attacker to spend or steal coins, it -does mean they could track all transactions involving your addresses -and therefore know your exact balance. In a future release, public data -encryption will extend to transactions as well. +btcwallet encrypts all private key material (private keys, HD seeds) +at rest using a single passphrase. Public data such as addresses, +transactions, and balances is stored in plaintext, consistent with +Bitcoin Core conventions. Users who require stronger privacy at rest +should use full disk encryption (e.g. LUKS). See +[ADR 0009](docs/developer/adr/0009-single-passphrase-encryption.md) +for design rationale. btcwallet is not an SPV client and requires connecting to a local or remote btcd instance for asynchronous blockchain queries and diff --git a/btcwallet.go b/btcwallet.go index 898ab90e15..c709ac8d5e 100644 --- a/btcwallet.go +++ b/btcwallet.go @@ -237,10 +237,16 @@ func rpcClientConnectLoop(legacyRPCServer *legacyrpc.Server, loader *wallet.Load loadedWallet.SetChainSynced(false) // TODO: Rework the wallet so changing the RPC client - // does not require stopping and restarting everything. - loadedWallet.Stop() + //nolint:staticcheck // This should be fixed once + // the interface refactor is finished, and new wallet + // RPC is built. + loadedWallet.StopDeprecated() loadedWallet.WaitForShutdown() - loadedWallet.Start() + + //nolint:staticcheck // This should be fixed once + // the interface refactor is finished, and new wallet + // RPC is built. + loadedWallet.StartDeprecated() } } } diff --git a/bwtest/README.md b/bwtest/README.md new file mode 100644 index 0000000000..880fb66083 --- /dev/null +++ b/bwtest/README.md @@ -0,0 +1,67 @@ +# bwtest + +`bwtest` contains the integration test harness used by `itest`. + +## Overview + +The harness provides: + +- A shared miner (btcd) that produces blocks for all test cases. +- A configurable chain backend under test (`btcd`, `bitcoind`, `neutrino`). +- Per-subtest resources: + - A fresh `chain.Interface` instance. + - A fresh wallet database instance. +- Cleanup that keeps tests isolated: + - Stops wallets created by the test. + - Requires the miner mempool to be empty on success. + +## Logs + +Each test run creates a per-run log directory under `itest/test-logs`. + +- Backend logs are flattened into `miner.log` and `chain_backend.log`. +- Wallet logs are written per test case as `wallet-.log`. + +## Backends + +Chain backends are implemented in separate files: + +- `bwtest/btcd.go` +- `bwtest/bitcoind.go` +- `bwtest/neutrino.go` + +The `bitcoind` backend uses ZMQ for block/tx notifications. + +## Wallet Helpers + +`bwtest` includes convenience helpers for tests that do not want to directly +exercise the wallet manager: + +- `(*HarnessTest).CreateEmptyWallet` +- `(*HarnessTest).CreateFundedWallet` + +Example usage: + +```go +func testFoo(t *bwtest.HarnessTest) { + t.CreateEmptyWallet() + + // Now add tests that need a started wallet instance. +} + +func testBar(t *bwtest.HarnessTest) { + t.CreateFundedWallet() + + // Now add tests that need a wallet with spendable funds. +} +``` + +Manager-focused tests should continue to create wallets through the manager API +directly. + +## Fast Scrypt + +`bwtest` sets `waddrmgr.DefaultScryptOptions` to `waddrmgr.FastScryptOptions` via +an `init()` function. Any package that imports `bwtest` (including `itest`) +automatically benefits from faster key derivation, avoiding CPU exhaustion and +timeouts — especially when running with `-race`. diff --git a/bwtest/backend.go b/bwtest/backend.go new file mode 100644 index 0000000000..bc6667f5a4 --- /dev/null +++ b/bwtest/backend.go @@ -0,0 +1,69 @@ +package bwtest + +import ( + "context" + "errors" + "testing" + + "github.com/btcsuite/btcwallet/chain" +) + +const ( + backendBtcd = "btcd" + backendBitcoind = "bitcoind" + backendNeutrino = "neutrino" +) + +var ( + errMissingMinerAddr = errors.New("missing miner address") +) + +// ChainBackend defines the interface that all chain backends must implement. +// +// A ChainBackend instance is shared across the whole itest suite run. +// Implementations must be safe to reuse across subtests that run serially. +type ChainBackend interface { + // Name returns the name of the backend ("btcd", "bitcoind", "neutrino"). + Name() string + + // Start launches the chain backend. + Start() error + + // Stop shuts down the chain backend. + Stop() error + + // ConnectMiner connects this backend to the miner. + ConnectMiner(minerAddr string) error + + // NewChainClient creates a new chain.Interface instance backed by this + // backend. + // + // This is expected to be called once for each subtest. The returned cleanup + // function must stop and release all resources created by the client. + NewChainClient(ctx context.Context) (chain.Interface, func(), error) + + // LogDir returns the directory where the backend writes its logs (if any). + LogDir() string +} + +// NewBackend creates a ChainBackend based on the type string. +func NewBackend(t *testing.T, backendType, logDir string) ChainBackend { + t.Helper() + + switch backendType { + case backendBtcd: + return NewBtcdBackend(t, logDir) + + case backendBitcoind: + return NewBitcoindBackend(t, logDir) + + case backendNeutrino: + // Neutrino is an in-process backend and does not require a backend log + // directory. + return NewNeutrinoBackend(t, logDir) + + default: + t.Fatalf("unknown chain backend %q", backendType) + return nil + } +} diff --git a/bwtest/bitcoind.go b/bwtest/bitcoind.go new file mode 100644 index 0000000000..b8c62c2c48 --- /dev/null +++ b/bwtest/bitcoind.go @@ -0,0 +1,394 @@ +package bwtest + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/btcsuite/btcd/rpcclient" + "github.com/btcsuite/btcwallet/bwtest/wait" + "github.com/btcsuite/btcwallet/chain" + "github.com/btcsuite/btcwallet/chain/port" + "github.com/stretchr/testify/require" +) + +const ( + // bitcoindRPCUser/bitcoindRPCPass are test-only credentials used by the + // chain client. They match the static rpcauth entry below. + bitcoindRPCUser = "weks" + bitcoindRPCPass = "weks" + + // bitcoindLogFilePerm protects daemon stdout/stderr logs written by the + // harness. + bitcoindLogFilePerm = 0o600 + + // bitcoindZMQReadDeadline bounds how long we wait on each ZMQ read. + bitcoindZMQReadDeadline = 5 * time.Second + // bitcoindMempoolPollingInterval controls fallback mempool polling cadence. + bitcoindMempoolPollingInterval = 100 * time.Millisecond + // bitcoindMaxConnections keeps descriptor requirements low in CI. + bitcoindMaxConnections = 16 + // bitcoindMaxMempoolMB reduces memory usage for short-lived test runs. + bitcoindMaxMempoolMB = 50 + + // bitcoindRPCAuthorization enables RPC access with user/pass without + // storing cleartext credentials in the datadir. + // + // Generated with: bitcoind -rpcauth=weks:weks. + bitcoindRPCAuthorization = "weks:469e9bb14ab2360f8e226efed5ca6f" + + "d$507c670e800a95284294edb5773b05544b" + + "220110063096c221be9933c82d38e1" +) + +var ( + errBitcoindNotSynced = errors.New("bitcoind not synced") +) + +// BitcoindBackend is a ChainBackend backed by a bitcoind process. +type BitcoindBackend struct { + // binary is the resolved bitcoind executable path. + binary string + + // cmd is the running bitcoind process. + cmd *exec.Cmd + + // logDir is the bitcoind data directory used by this backend instance. + logDir string + + // rpcPort is the HTTP-RPC port used by bitcoind. + rpcPort int + // p2pPort is the inbound/outbound p2p port used by bitcoind. + p2pPort int + + // zmqBlockHost publishes raw block notifications for chain clients. + zmqBlockHost string + // zmqTxHost publishes raw transaction notifications for chain clients. + zmqTxHost string + + // minerAddr is the shared miner peer address that bitcoind connects to. + minerAddr string + + // stdoutPath/stderrPath are harness-managed daemon log files. + stdoutPath string + stderrPath string + + // stdoutFile/stderrFile stay open for the lifetime of the daemon process. + stdoutFile *os.File + stderrFile *os.File + + // cmdCancel cancels the process context to unblock shutdown paths. + cmdCancel context.CancelFunc +} + +// NewBitcoindBackend creates a new BitcoindBackend. +// +// The backend writes its stdout/stderr into the passed logDir and uses ZMQ for +// block and transaction notifications. +func NewBitcoindBackend(t *testing.T, logDir string) *BitcoindBackend { + t.Helper() + + bitcoindBinary, err := GetBitcoindBinary() + require.NoError(t, err, "unable to find bitcoind binary") + + absLogDir, err := filepath.Abs(logDir) + require.NoError(t, err, "unable to get absolute bitcoind log dir") + + err = ensureLogDir(absLogDir) + require.NoError(t, err, "unable to create bitcoind log dir") + + // Reserve ports in a stable order so diagnostics are easier to read when a + // setup step fails and reports one of these endpoints. + zmqBlockPort := port.NextAvailablePort() + zmqTxPort := port.NextAvailablePort() + rpcPort := port.NextAvailablePort() + p2pPort := port.NextAvailablePort() + + zmqBlockHost := fmt.Sprintf("tcp://127.0.0.1:%d", zmqBlockPort) + zmqTxHost := fmt.Sprintf("tcp://127.0.0.1:%d", zmqTxPort) + + return &BitcoindBackend{ + binary: bitcoindBinary, + logDir: absLogDir, + rpcPort: rpcPort, + p2pPort: p2pPort, + zmqBlockHost: zmqBlockHost, + zmqTxHost: zmqTxHost, + stdoutPath: filepath.Join(absLogDir, "bitcoind.stdout.log"), + stderrPath: filepath.Join(absLogDir, "bitcoind.stderr.log"), + } +} + +// Name returns the identifier of the backend. +func (b *BitcoindBackend) Name() string { + return backendBitcoind +} + +// Start launches the backend daemon. +func (b *BitcoindBackend) Start() error { + // Startup sequence overview: + // 1. Validate harness wiring (miner address + process limits). + // 2. Build daemon arguments (regtest, rpc, zmq, resource limits). + // 3. Redirect stdout/stderr to harness-managed log files. + // 4. Start the daemon process and retain file handles. + // 5. Probe RPC until the node is responsive and chain-synced. + // + // If any step fails we return an error that points to the collected logs so + // CI failures can be diagnosed from artifacts. + if b.minerAddr == "" { + return fmt.Errorf("bitcoind: %w", errMissingMinerAddr) + } + + // Best-effort attempt to increase the file descriptor limit before starting + // bitcoind. This helps avoid startup failures on systems with a low default + // RLIMIT_NOFILE. + _ = raiseNoFileLimit() + + args := []string{ + // Core regtest + connectivity setup. + "-datadir=" + b.logDir, + "-regtest", + "-connect=" + b.minerAddr, + + // bitcoind enables P2P v2 by default, but the shared btcd miner keeps + // v2 transport disabled by default. Pin bitcoind to v1 here so harness + // startup does not rely on downgrade timing during the first peer + // handshake. + "-v2transport=0", + + // Enable wallet-required indexing and RPC auth. + "-txindex", + "-disablewallet", + "-rpcauth=" + bitcoindRPCAuthorization, + fmt.Sprintf("-rpcport=%d", b.rpcPort), + fmt.Sprintf("-port=%d", b.p2pPort), + + // Use ZMQ notifications (blocks + txs) for low-latency chain updates. + "-zmqpubrawblock=" + b.zmqBlockHost, + "-zmqpubrawtx=" + b.zmqTxHost, + "-blockfilterindex=1", + + // Reduce resource usage for test environments. + // + // NOTE: bitcoind performs a file descriptor sanity check on startup. + // Keeping the connection count low reduces the required number of file + // descriptors. + fmt.Sprintf("-maxconnections=%d", bitcoindMaxConnections), + fmt.Sprintf("-maxmempool=%d", bitcoindMaxMempoolMB), + } + + cmdCtx, cmdCancel := context.WithCancel(context.Background()) + b.cmdCancel = cmdCancel + + // #nosec G204 -- b.binary is looked up from PATH and args are controlled. + cmd := exec.CommandContext(cmdCtx, b.binary, args...) + + // #nosec G304 -- b.stdoutPath is created by the test harness. + stdout, err := os.OpenFile( + b.stdoutPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, bitcoindLogFilePerm, + ) + if err != nil { + return fmt.Errorf("open bitcoind stdout log: %w", err) + } + + // #nosec G304 -- b.stderrPath is created by the test harness. + stderr, err := os.OpenFile( + b.stderrPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, bitcoindLogFilePerm, + ) + if err != nil { + _ = stdout.Close() + return fmt.Errorf("open bitcoind stderr log: %w", err) + } + + cmd.Stdout = stdout + cmd.Stderr = stderr + + err = cmd.Start() + if err != nil { + _ = stdout.Close() + _ = stderr.Close() + + return fmt.Errorf("start bitcoind: %w", err) + } + + // Keep handles alive for the duration of the process. + b.cmd = cmd + b.stdoutFile = stdout + b.stderrFile = stderr + + // Wait until bitcoind is ready to serve RPC calls. + // + // This readiness check also verifies that bitcoind has synced to the + // pre-mined harness chain height. + host := fmt.Sprintf("127.0.0.1:%d", b.rpcPort) + clientCfg := &rpcclient.ConnConfig{ + Host: host, + User: bitcoindRPCUser, + Pass: bitcoindRPCPass, + DisableAutoReconnect: false, + DisableConnectOnNew: true, + DisableTLS: true, + HTTPPostMode: true, + } + + err = wait.NoError(func() error { + // Construct a short-lived RPC client for readiness probing. + client, err := rpcclient.New(clientCfg, nil) + if err != nil { + return fmt.Errorf("create bitcoind rpc client: %w", err) + } + + defer func() { + client.Shutdown() + client.WaitForShutdown() + }() + + _, err = client.GetBlockChainInfo() + if err != nil { + return fmt.Errorf("get blockchain info: %w", err) + } + + count, err := client.GetBlockCount() + if err != nil { + return fmt.Errorf("get block count: %w", err) + } + + if count < int64(minMatureBlocks) { + return fmt.Errorf("%w (height=%d)", errBitcoindNotSynced, + count) + } + + return nil + }, defaultTestTimeout) + if err != nil { + _ = b.Stop() + + const errFmt = "bitcoind not ready: %w; logs: %s %s" + + return fmt.Errorf(errFmt, err, b.stdoutPath, b.stderrPath) + } + + return nil +} + +// Stop shuts down the backend daemon. +func (b *BitcoindBackend) Stop() error { + if b.cmdCancel != nil { + b.cmdCancel() + b.cmdCancel = nil + } + + if b.cmd != nil && b.cmd.Process != nil { + _ = b.cmd.Process.Kill() + _ = b.cmd.Wait() + } + + // Mark the process handle as stopped so repeated Stop calls are no-ops. + b.cmd = nil + + if b.stdoutFile != nil { + _ = b.stdoutFile.Close() + b.stdoutFile = nil + } + + if b.stderrFile != nil { + _ = b.stderrFile.Close() + b.stderrFile = nil + } + + return nil +} + +// ConnectMiner records the miner address for later use. +func (b *BitcoindBackend) ConnectMiner(minerAddr string) error { + b.minerAddr = minerAddr + + return nil +} + +// NewChainClient creates a new bitcoind-backed chain.Interface connected to +// this backend. +// +// For each subtest, we create a fresh BitcoindConn and client pair so test +// teardown can fully dispose chain resources without affecting other subtests. +// Startup order matters: +// 1. Construct BitcoindConn. +// 2. Start the connection so RPC + ZMQ subscriptions become active. +// 3. Construct and start the chain client that wallets will use. +// +// Cleanup runs in reverse order to avoid races between client shutdown and +// connection teardown. +func (b *BitcoindBackend) NewChainClient(ctx context.Context) (chain.Interface, + func(), error) { + + // Create a fresh chain connection for each subtest. + host := fmt.Sprintf("127.0.0.1:%d", b.rpcPort) + cfg := &chain.BitcoindConfig{ + ChainParams: harnessNetParams, + Host: host, + User: bitcoindRPCUser, + Pass: bitcoindRPCPass, + Dialer: nil, + PrunedModeMaxPeers: 0, + // ZMQ endpoints are passed in the same block/tx order used by the + // daemon startup flags above. + ZMQConfig: &chain.ZMQConfig{ + ZMQBlockHost: b.zmqBlockHost, + ZMQTxHost: b.zmqTxHost, + ZMQReadDeadline: bitcoindZMQReadDeadline, + MempoolPollingInterval: bitcoindMempoolPollingInterval, + }, + } + + var ( + conn *chain.BitcoindConn + err error + ) + + err = wait.NoError(func() error { + conn, err = chain.NewBitcoindConn(cfg) + if err != nil { + return fmt.Errorf("create bitcoind conn: %w", err) + } + + return nil + }, defaultTestTimeout) + if err != nil { + return nil, nil, fmt.Errorf("create bitcoind conn: %w", err) + } + + err = conn.Start() + if err != nil { + return nil, nil, fmt.Errorf("start bitcoind conn: %w", err) + } + + client, err := conn.NewBitcoindClient() + if err != nil { + conn.Stop() + return nil, nil, fmt.Errorf("create bitcoind client: %w", err) + } + + err = client.Start(ctx) + if err != nil { + conn.Stop() + return nil, nil, fmt.Errorf("start bitcoind client: %w", err) + } + + cleanup := func() { + client.Stop() + conn.Stop() + } + + return client, cleanup, nil +} + +// LogDir returns the directory where bitcoind wrote its logs for this run. +func (b *BitcoindBackend) LogDir() string { + return b.logDir +} + +var _ ChainBackend = (*BitcoindBackend)(nil) diff --git a/bwtest/btcd.go b/bwtest/btcd.go new file mode 100644 index 0000000000..205def545e --- /dev/null +++ b/bwtest/btcd.go @@ -0,0 +1,138 @@ +package bwtest + +import ( + "context" + "fmt" + "testing" + + "github.com/btcsuite/btcd/integration/rpctest" + "github.com/btcsuite/btcd/rpcclient" + "github.com/btcsuite/btcwallet/chain" + "github.com/stretchr/testify/require" +) + +// BtcdBackend is a ChainBackend backed by a btcd node (via rpctest). +type BtcdBackend struct { + // harness is the underlying rpctest harness that manages the btcd process. + harness *rpctest.Harness + + // logDir is the directory where btcd writes its logs. + logDir string + + // minerAddr is the P2P address of the shared miner. + minerAddr string +} + +// NewBtcdBackend creates a new BtcdBackend. +func NewBtcdBackend(t *testing.T, logDir string) *BtcdBackend { + t.Helper() + + btcdBinary, err := GetBtcdBinary() + require.NoError(t, err, "unable to find btcd binary") + + err = ensureLogDir(logDir) + require.NoError(t, err, "unable to create btcd backend log dir") + + // Create a separate harness for the chain backend. + args := []string{ + "--rejectnonstd", // Reject non-standard txs in tests. + "--txindex", // Required for some RPC queries. + "--nowinservice", // Avoid Windows service integration. + "--nobanning", // Avoid peer banning in local tests. + "--debuglevel=debug", // Provide detailed logs for debugging. + "--logdir=" + logDir, // Write logs into our per-run dir. + "--trickleinterval=100ms", // Speed up inv relay in regtest. + "--nostalldetect", // Avoid stall detection flakiness. + } + + handlers := &rpcclient.NotificationHandlers{} + harness, err := rpctest.New(harnessNetParams, handlers, args, btcdBinary) + require.NoError(t, err, "unable to create btcd backend harness") + + return &BtcdBackend{ + harness: harness, + logDir: logDir, + } +} + +// Name returns the identifier of the backend. +func (b *BtcdBackend) Name() string { + return backendBtcd +} + +// Start launches the backend daemon. +func (b *BtcdBackend) Start() error { + // SetUp(false, 0) means we don't treat it as a miner and don't cache block + // templates. + err := b.harness.SetUp(false, 0) + if err != nil { + return fmt.Errorf("setup btcd harness: %w", err) + } + + if b.minerAddr == "" { + return fmt.Errorf("btcd: %w", errMissingMinerAddr) + } + + // Connect the backend to the miner after the node is up. + err = b.harness.Client.AddNode(b.minerAddr, "add") + if err != nil { + return fmt.Errorf("add miner node %s: %w", b.minerAddr, err) + } + + return nil +} + +// Stop shuts down the backend daemon. +func (b *BtcdBackend) Stop() error { + err := b.harness.TearDown() + if err != nil { + return fmt.Errorf("teardown btcd harness: %w", err) + } + + return nil +} + +// ConnectMiner records the miner address for later use. +func (b *BtcdBackend) ConnectMiner(minerAddr string) error { + b.minerAddr = minerAddr + + return nil +} + +// NewChainClient creates a new RPC-backed chain.Interface connected to this +// backend. +func (b *BtcdBackend) NewChainClient(ctx context.Context) (chain.Interface, + func(), error) { + + backendCfg := b.harness.RPCConfig() + rpcCfg := backendCfg + + chainConfig := &chain.RPCClientConfig{ + Conn: &rpcCfg, + Chain: harnessNetParams, + ReconnectAttempts: defaultChainReconnectAttempts, + } + + chainClient, err := chain.NewRPCClientWithConfig(chainConfig) + if err != nil { + return nil, nil, fmt.Errorf("create chain client: %w", err) + } + + err = chainClient.Start(ctx) + if err != nil { + return nil, nil, fmt.Errorf("start chain client: %w", err) + } + + cleanup := func() { + chainClient.Stop() + } + + return chainClient, cleanup, nil +} + +// LogDir returns the directory where btcd wrote its logs for this run. +func (b *BtcdBackend) LogDir() string { + return b.logDir +} + +var _ ChainBackend = (*BtcdBackend)(nil) diff --git a/bwtest/database.go b/bwtest/database.go new file mode 100644 index 0000000000..5fc84f17fe --- /dev/null +++ b/bwtest/database.go @@ -0,0 +1,53 @@ +package bwtest + +import ( + "errors" + "fmt" + "path/filepath" + + "github.com/btcsuite/btcwallet/walletdb" + _ "github.com/btcsuite/btcwallet/walletdb/bdb" // Register bdb driver. +) + +var ( + // ErrUnknownDBBackend is returned when an unknown db backend is requested. + ErrUnknownDBBackend = errors.New("unknown db backend") +) + +const ( + // dbNameKvdb is the identifier used for the kvdb wallet backend. + dbNameKvdb = "kvdb" + + // kvdbDriver is the walletdb driver name used for kvdb. + kvdbDriver = "bdb" + + // walletDBFilename is the default wallet database filename. + walletDBFilename = "wallet.db" +) + +// OpenWalletDB opens a wallet database instance rooted at baseDir. +// +// The returned cleanup function should be called to close the database. +func OpenWalletDB(dbType, baseDir string) (walletdb.DB, func() error, error) { + switch dbType { + case dbNameKvdb: + dbPath := filepath.Join(baseDir, walletDBFilename) + + db, err := walletdb.Create(kvdbDriver, dbPath, true, + defaultTestTimeout, false) + if err != nil { + return nil, nil, fmt.Errorf("unable to create bdb instance: %w", + err) + } + + cleanup := func() error { + return db.Close() + } + + return db, cleanup, nil + + // TODO: Add sqlite and postgres support. + default: + return nil, nil, fmt.Errorf("%w: %s", ErrUnknownDBBackend, dbType) + } +} diff --git a/bwtest/doc.go b/bwtest/doc.go new file mode 100644 index 0000000000..bf125c105d --- /dev/null +++ b/bwtest/doc.go @@ -0,0 +1,3 @@ +// Package bwtest contains the integration test harness used by the itest +// package. +package bwtest diff --git a/bwtest/harness.go b/bwtest/harness.go new file mode 100644 index 0000000000..debcb53bdf --- /dev/null +++ b/bwtest/harness.go @@ -0,0 +1,312 @@ +package bwtest + +import ( + "context" + "fmt" + "path/filepath" + "runtime/debug" + "sync" + "testing" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcwallet/chain" + "github.com/btcsuite/btcwallet/wallet" + "github.com/btcsuite/btcwallet/walletdb" + "github.com/stretchr/testify/require" +) + +const ( + // defaultChainReconnectAttempts is the number of times the chain RPC client + // will attempt to reconnect before failing. + defaultChainReconnectAttempts = 5 +) + +// HarnessTest is the integration test harness. +type HarnessTest struct { + *testing.T + + // logDir is the per-run root log directory. + logDir string + + // miner is the shared mining node used to generate blocks. + miner *minerHarness + + // Backend is the chain backend under test. + Backend ChainBackend + + // ChainClient is an RPC chain client connected to the active chain backend. + // + // This client is created for each subtest harness and is intended to be + // passed to wallets under test. + ChainClient chain.Interface + + // WalletDB is a wallet database instance created for the current subtest. + WalletDB walletdb.DB + + // dbType is the configured wallet database backend. + dbType string + + // mu protects harness state that can be accessed across the main test and + // subtests. This includes the wallet registry and idempotent shutdown. + mu sync.Mutex + + // wallets is the set of wallets created by a test case. + wallets []*wallet.Wallet + + // stopped prevents stopping shared infrastructure more than once. + stopped bool + + // cleaned indicates the subtest cleanup has already run. + cleaned bool +} + +// SetupHarness creates a new HarnessTest. +func SetupHarness(t *testing.T, chainBackendType, dbType string) *HarnessTest { + t.Helper() + + logDir := createTestLogDir(t, chainBackendType, dbType) + + // 1. Start Miner (always btcd). + minerLogDir := createOrEnsureLogSubDir(t, logDir, "miner") + miner := newMiner(t, minerLogDir) + miner.SetUp() + require.NoError(t, waitForTCPListener(miner.P2PAddress(), + defaultTestTimeout), "miner p2p listener not ready") + + // 2. Start Chain Backend. + backendLogDir := "" + + // Neutrino runs in-process and has no separate daemon log directory. The + // external daemon backends (btcd/bitcoind) each get a dedicated backend log + // sub-directory. + if chainBackendType != backendNeutrino { + backendLogDir = createOrEnsureLogSubDir(t, logDir, "chain-backend") + } + + backend := NewBackend(t, chainBackendType, backendLogDir) + require.NoError(t, backend.ConnectMiner(miner.P2PAddress())) + require.NoError(t, backend.Start(), "failed to start chain backend") + + ht := &HarnessTest{ + T: t, + logDir: logDir, + miner: miner, + Backend: backend, + dbType: dbType, + } + + // Ensure the harness is cleaned up when the test finishes. + t.Cleanup(ht.Stop) + + return ht +} + +// Subtest creates a child harness that shares the miner and chain backend. +// +// The returned harness has its own wallet registry and per-test resources. +// Callers should not call Stop on the returned harness as it would stop shared +// infrastructure. +func (h *HarnessTest) Subtest(t *testing.T) *HarnessTest { + h.Helper() + + st := &HarnessTest{ + T: t, + logDir: h.logDir, + miner: h.miner, + Backend: h.Backend, + dbType: h.dbType, + } + + // Use the subtest's testing context for miner assertions. + // + // The miner is shared across test cases, but we want failures to be + // attributed to the active subtest. + // + // NOTE: The miner is shared across the whole suite and this assignment + // mutates that shared state. + // + // This is safe because the integration test suite runs subtests serially + // (no t.Parallel()). Do not enable parallel integration cases unless this + // is refactored. + st.miner.T = st.T + + walletLogCleanup := setUpWalletLogging( + t, filepath.Join(st.logDir, walletLogFileName(t)), + ) + st.Cleanup(walletLogCleanup) + + st.setUpChainClient() + st.setUpWalletDB() + + st.Cleanup(func() { + // If a test fails, we still try to stop wallets to avoid leaking + // goroutines into the next test, but we skip assertions. + if st.Failed() { + err := st.stopActiveWallets(context.Background()) + if err != nil { + st.Logf("failed to stop wallets during failed-test cleanup: %v", + err) + } + + return + } + + if st.cleaned { + return + } + + err := st.stopActiveWallets(context.Background()) + require.NoError(st, err, "failed to stop wallets") + + mempool, err := st.getRawMempool() + require.NoError(st, err, "failed to query miner mempool") + require.Empty(st, mempool, "mempool not cleaned") + + st.cleaned = true + h.cleaned = true + }) + + return st +} + +// RegisterWallet registers a wallet with the harness. +// +// Registered wallets are automatically included in harness-level assertions, +// such as MineBlocks. +func (h *HarnessTest) RegisterWallet(w *wallet.Wallet) { + h.Helper() + + if w == nil { + h.Fatalf("cannot register nil wallet") + } + + h.mu.Lock() + h.wallets = append(h.wallets, w) + h.mu.Unlock() +} + +// ActiveWallets returns a snapshot of wallets registered with this harness. +func (h *HarnessTest) ActiveWallets() []*wallet.Wallet { + h.Helper() + + h.mu.Lock() + wallets := append([]*wallet.Wallet(nil), h.wallets...) + h.mu.Unlock() + + return wallets +} + +// RunTestCase executes a harness test case. +// +// Any panic from the test function is converted into a fatal test failure with +// a stack trace. +func (h *HarnessTest) RunTestCase(name string, + testFunc func(t *HarnessTest)) { + + h.Helper() + + defer func() { + r := recover() + if r == nil { + return + } + + stack := debug.Stack() + h.Fatalf("failed (%s): panic=%v\n%s", name, r, stack) + }() + + if testFunc == nil { + h.Fatalf("nil test func for %s", name) + } + + // Execute the case. + testFunc(h) +} + +// NetParams returns the chain parameters used by the harness. +func (h *HarnessTest) NetParams() *chaincfg.Params { + h.Helper() + + return harnessNetParams +} + +// Stop shuts down all resources owned by the harness. +func (h *HarnessTest) Stop() { + h.Helper() + + h.mu.Lock() + + if h.stopped { + h.mu.Unlock() + return + } + + h.stopped = true + h.mu.Unlock() + + // Stop the chain backend first to avoid it attempting to reconnect while + // the miner is being torn down. + require.NoError(h, h.Backend.Stop(), "failed to stop chain backend") + + // Finally, stop the miner. + h.miner.Stop() + + // Flatten logs into the per-run log dir. + h.finalizeLogs() +} + +// stopActiveWallets stops all wallets registered with the harness. +// +// This is used as part of the per-subtest cleanup to avoid leaking background +// goroutines into the next test. +func (h *HarnessTest) stopActiveWallets(ctx context.Context) error { + h.Helper() + + for _, w := range h.ActiveWallets() { + if w == nil { + // Keep cleanup robust against partially initialized test state. A + // caller could register a wallet reference and fail before the + // assignment completes. + continue + } + + // The modern Wallet controller's Stop method is idempotent. + // + // NOTE: We intentionally don't call the deprecated WaitForShutdown/ + // ShuttingDown methods here, as modern wallets might not have the + // legacy fields initialized. + err := w.Stop(ctx) + if err != nil { + return fmt.Errorf("stop wallet: %w", err) + } + } + + return nil +} + +// setUpChainClient creates and starts a chain client for the active harness +// backend. +func (h *HarnessTest) setUpChainClient() { + h.Helper() + + chainClient, cleanup, err := h.Backend.NewChainClient(h.Context()) + require.NoError(h, err, "unable to create chain client") + + h.Cleanup(cleanup) + h.ChainClient = chainClient +} + +// setUpWalletDB opens a wallet database for the configured test backend. +func (h *HarnessTest) setUpWalletDB() { + h.Helper() + + dbDir := h.TempDir() + db, cleanup, err := OpenWalletDB(h.dbType, dbDir) + require.NoError(h, err, "unable to create wallet db") + + h.Cleanup(func() { + require.NoError(h, cleanup(), "failed to close database") + }) + + h.WalletDB = db +} diff --git a/bwtest/harness_assertions.go b/bwtest/harness_assertions.go new file mode 100644 index 0000000000..ac62a554df --- /dev/null +++ b/bwtest/harness_assertions.go @@ -0,0 +1,44 @@ +package bwtest + +import ( + "errors" + "fmt" + + "github.com/btcsuite/btcwallet/bwtest/wait" + "github.com/btcsuite/btcwallet/wallet" +) + +var ( + // ErrWalletNotSynced is returned when a wallet has not reached the chain + // tip. + ErrWalletNotSynced = errors.New("wallet not synced") +) + +// AssertWalletSynced polls until the wallet reports it is synced to the +// miner's best known height. +func (h *HarnessTest) AssertWalletSynced(w *wallet.Wallet) { + h.Helper() + + if w == nil { + h.Fatalf("nil wallet") + } + + err := wait.NoError(func() error { + syncedTo := w.SyncedTo() + + _, bestHeight, err := h.miner.Client.GetBestBlock() + if err != nil { + return fmt.Errorf("get best block: %w", err) + } + + if syncedTo.Height != bestHeight { + return fmt.Errorf("%w: wallet=%d chain=%d", ErrWalletNotSynced, + syncedTo.Height, bestHeight) + } + + return nil + }, defaultTestTimeout) + if err != nil { + h.Fatalf("wallet sync timeout: %v", err) + } +} diff --git a/bwtest/harness_miner.go b/bwtest/harness_miner.go new file mode 100644 index 0000000000..5c1410aa40 --- /dev/null +++ b/bwtest/harness_miner.go @@ -0,0 +1,506 @@ +package bwtest + +import ( + "errors" + "fmt" + "strings" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/bwtest/wait" + "github.com/stretchr/testify/require" +) + +var ( + // ErrMempoolTxNotFound is returned when a transaction is not found in the + // miner's mempool. + ErrMempoolTxNotFound = errors.New("transaction not found in mempool") + + // ErrMempoolTxFound is returned when a transaction is found in the miner's + // mempool. + ErrMempoolTxFound = errors.New("transaction found in mempool") + + // ErrMempoolNumTxnsMismatch is returned when the number of transactions in + // the mempool doesn't match the expected value. + ErrMempoolNumTxnsMismatch = errors.New("mempool txn count mismatch") + + // ErrBlockMissingTx is returned when a transaction is not found in a block. + ErrBlockMissingTx = errors.New("transaction not found in block") + + // ErrBlockUnexpectedTxns is returned when a block contains unexpected + // transactions. + ErrBlockUnexpectedTxns = errors.New("block contains unexpected txns") + + // ErrOutpointNotFound is returned when an outpoint is not found in the + // miner's mempool. + ErrOutpointNotFound = errors.New("outpoint not found in mempool") + + // ErrNilBlock is returned when a nil block is provided. + ErrNilBlock = errors.New("nil block") + + // ErrMinerNotSynced is returned when a temporary miner is not synced to the + // harness miner. + ErrMinerNotSynced = errors.New("miner not synced") +) + +const ( + // coinbaseAndOneTxn is the number of transactions expected in a block that + // contains only a coinbase transaction and a single non-coinbase + // transaction. + coinbaseAndOneTxn = 2 +) + +// GenerateBlocks generates the specified number of blocks. +func (h *HarnessTest) GenerateBlocks(num uint32) []*chainhash.Hash { + h.Helper() + + hashes, err := h.miner.Client.Generate(num) + require.NoError(h, err, "unable to generate blocks") + + return hashes +} + +// AssertTxInMempool asserts a transaction can be found in the miner's mempool. +func (h *HarnessTest) AssertTxInMempool(txid chainhash.Hash) *wire.MsgTx { + h.Helper() + + var foundTx *wire.MsgTx + + err := wait.NoError(func() error { + txids, err := h.getRawMempool() + if err != nil { + return fmt.Errorf("get raw mempool: %w", err) + } + + for _, memTxid := range txids { + if memTxid == txid { + tx, err := h.miner.Client.GetRawTransaction(&txid) + if err != nil { + return fmt.Errorf("get raw transaction: %w", err) + } + + foundTx = tx.MsgTx() + + return nil + } + } + + return fmt.Errorf("%w: txid=%s", ErrMempoolTxNotFound, txid) + }, defaultTestTimeout) + require.NoError(h, err, "timeout waiting for txn in mempool") + require.NotNil(h, foundTx, "found tx is nil") + + return foundTx +} + +// AssertTxNotInMempool asserts a transaction cannot be found in the miner's +// mempool. +func (h *HarnessTest) AssertTxNotInMempool(txid chainhash.Hash) { + h.Helper() + + err := wait.NoError(func() error { + txids, err := h.getRawMempool() + if err != nil { + return fmt.Errorf("get raw mempool: %w", err) + } + + for _, memTxid := range txids { + if memTxid == txid { + return fmt.Errorf("%w: txid=%s", ErrMempoolTxFound, + txid) + } + } + + return nil + }, defaultTestTimeout) + require.NoError(h, err, "timeout waiting for txn to leave mempool") +} + +// AssertNumTxnsInMempool polls until finding the expected number of +// transactions in the miner's mempool. +func (h *HarnessTest) AssertNumTxnsInMempool(n int) []chainhash.Hash { + h.Helper() + + if n < 0 { + h.Fatalf("invalid mempool size: %d", n) + } + + var txids []chainhash.Hash + + err := wait.NoError(func() error { + mempoolTxids, err := h.getRawMempool() + if err != nil { + return fmt.Errorf("get raw mempool: %w", err) + } + + if len(mempoolTxids) != n { + return fmt.Errorf("%w: want=%d got=%d", ErrMempoolNumTxnsMismatch, + n, len(mempoolTxids)) + } + + txids = mempoolTxids + + return nil + }, defaultTestTimeout) + require.NoError(h, err, "timeout waiting for mempool size") + + return txids +} + +// AssertOutpointInMempool asserts an outpoint is spent by a transaction in the +// miner's mempool. +func (h *HarnessTest) AssertOutpointInMempool(op wire.OutPoint) *wire.MsgTx { + h.Helper() + + var foundTx *wire.MsgTx + + err := wait.NoError(func() error { + txids, err := h.getRawMempool() + if err != nil { + return fmt.Errorf("get raw mempool: %w", err) + } + + for _, txid := range txids { + tx, err := h.miner.Client.GetRawTransaction(&txid) + if err != nil { + return fmt.Errorf("get raw transaction: %w", err) + } + + msgTx := tx.MsgTx() + for _, txIn := range msgTx.TxIn { + if txIn.PreviousOutPoint == op { + foundTx = msgTx + return nil + } + } + } + + return fmt.Errorf("%w: outpoint=%v", ErrOutpointNotFound, op) + }, defaultTestTimeout) + require.NoError(h, err, "timeout waiting for outpoint in mempool") + require.NotNil(h, foundTx, "found tx is nil") + + return foundTx +} + +// AssertTxInBlock asserts a transaction can be found in a block. +func (h *HarnessTest) AssertTxInBlock(block *wire.MsgBlock, + txid chainhash.Hash) { + + h.Helper() + + if block == nil { + h.Fatalf("nil block") + } + + for _, tx := range block.Transactions { + if tx == nil { + continue + } + + if tx.TxHash() == txid { + return + } + } + + h.Fatalf("%v: block=%v", fmt.Errorf("%w: txid=%s", ErrBlockMissingTx, + txid), block.BlockHash()) +} + +// MineBlocks mines blocks and asserts no transactions are found in the mined +// blocks. +// +// After each block is mined, all registered wallets are required to be synced. +func (h *HarnessTest) MineBlocks(num int) { + h.Helper() + + err := h.MineBlocksNoTxns(num) + if err != nil { + require.Fail(h, "MineBlocks", err.Error()) + } +} + +// MineBlocksNoTxns mines blocks and returns an error if any mined block +// contains non-coinbase transactions. +func (h *HarnessTest) MineBlocksNoTxns(num int) error { + h.Helper() + + blocks := h.generateBlocks(num) + for _, b := range blocks { + err := h.blockHasNoTxns(b) + if err != nil { + return err + } + } + + return nil +} + +// MineEmptyBlocks mines blocks and asserts the mempool remains empty. +// +// This differs from MineBlocks in that it explicitly requires the miner's +// mempool to have no transactions before mining begins. +func (h *HarnessTest) MineEmptyBlocks(num int) []*wire.MsgBlock { + h.Helper() + + // Require the mempool is empty before mining, otherwise these blocks might + // confirm pending transactions. + h.AssertNumTxnsInMempool(0) + + blocks := h.generateBlocks(num) + for _, b := range blocks { + err := h.blockHasNoTxns(b) + if err != nil { + require.Fail(h, "MineEmptyBlocks", err.Error()) + } + } + + return blocks +} + +// MineBlocksAndAssertNumTxns mines blocks and asserts that numTxns +// transactions are included in the first mined block. +func (h *HarnessTest) MineBlocksAndAssertNumTxns(num uint32, + numTxns int) []*wire.MsgBlock { + + h.Helper() + + if num == 0 { + h.Fatalf("invalid block count: %d", num) + } + + txids := h.AssertNumTxnsInMempool(numTxns) + blocks := h.generateBlocks(int(num)) + + for _, txid := range txids { + h.AssertTxInBlock(blocks[0], txid) + h.AssertTxNotInMempool(txid) + } + + return blocks +} + +// MineBlockWithTx mines a single block and asserts it contains the given +// transaction. +func (h *HarnessTest) MineBlockWithTx(tx *wire.MsgTx) *wire.MsgBlock { + h.Helper() + + if tx == nil { + h.Fatalf("nil tx") + } + + txid := tx.TxHash() + h.AssertTxInMempool(txid) + + // Ensure the mempool only contains our transaction so the mined block + // contains only the coinbase and this transaction. + mempoolTxids := h.AssertNumTxnsInMempool(1) + require.Equal(h, txid, mempoolTxids[0], "unexpected txn in mempool") + + blocks := h.MineBlocksAndAssertNumTxns(1, 1) + require.Len(h, blocks, 1, "expected exactly 1 block") + + block := blocks[0] + require.NotNil(h, block, "mined block is nil") + require.Len(h, block.Transactions, coinbaseAndOneTxn, + "expected coinbase and one txn") + require.Equal(h, txid, block.Transactions[1].TxHash(), + "unexpected txn in mined block") + + return block +} + +// SpawnTempMiner creates a temporary miner that is synced with the current +// miner. +// +// This is useful for reorg tests where an alternative chain needs to be mined +// in isolation. +func (h *HarnessTest) SpawnTempMiner() *HarnessTest { + h.Helper() + + minerLogDir := createUniqueLogSubDir(h.T, h.logDir, "miner-temp") + tempMiner := newMiner(h.T, minerLogDir) + tempMiner.SetUpNoChain() + + th := &HarnessTest{T: h.T, logDir: h.logDir, miner: tempMiner} + h.Cleanup(tempMiner.Stop) + + // Connect the miners and wait for the temp miner to sync. + h.ConnectToMiner(th) + + _, mainHeight := h.GetBestBlock() + err := wait.NoError(func() error { + _, tempHeight, err := tempMiner.Client.GetBestBlock() + if err != nil { + return fmt.Errorf("get best block: %w", err) + } + + if tempHeight != mainHeight { + return fmt.Errorf("%w: main=%d temp=%d", ErrMinerNotSynced, + mainHeight, tempHeight) + } + + return nil + }, defaultTestTimeout) + require.NoError(h, err, "timeout waiting for temp miner to sync") + + // Disconnect the temp miner so it can mine an alternative chain. + h.DisconnectFromMiner(th) + + return th +} + +// ConnectToMiner connects the harness miner to tempMiner. +func (h *HarnessTest) ConnectToMiner(tempMiner *HarnessTest) { + h.Helper() + + if tempMiner == nil { + h.Fatalf("nil temp miner") + } + + if tempMiner.miner == nil { + h.Fatalf("nil temp miner harness") + } + + err := h.miner.Client.AddNode(tempMiner.miner.P2PAddress(), "add") + require.NoError(h, err, "failed to connect to temp miner") + + err = tempMiner.miner.Client.AddNode(h.miner.P2PAddress(), "add") + require.NoError(h, err, "failed to connect temp miner") +} + +// DisconnectFromMiner disconnects the harness miner from tempMiner. +func (h *HarnessTest) DisconnectFromMiner(tempMiner *HarnessTest) { + h.Helper() + + if tempMiner == nil { + h.Fatalf("nil temp miner") + } + + if tempMiner.miner == nil { + h.Fatalf("nil temp miner harness") + } + + err := h.miner.Client.AddNode(tempMiner.miner.P2PAddress(), "remove") + require.NoError(h, err, "failed to disconnect from temp miner") + + err = tempMiner.miner.Client.AddNode(h.miner.P2PAddress(), "remove") + require.NoError(h, err, "failed to disconnect temp miner") +} + +// generateBlocks mines num blocks and returns the full blocks. +// +// After each block is mined, all registered wallets are required to be synced. +func (h *HarnessTest) generateBlocks(num int) []*wire.MsgBlock { + h.Helper() + + // Mining 0 blocks is a no-op. + if num == 0 { + return nil + } + + if num < 0 { + h.Fatalf("invalid block count: %d", num) + } + + if num > int(^uint32(0)) { + h.Fatalf("too many blocks requested: %d", num) + } + + blocks := make([]*wire.MsgBlock, 0, num) + for range num { + hashes := h.GenerateBlocks(1) + require.Len(h, hashes, 1, "expected 1 block hash") + + block, err := h.miner.Client.GetBlock(hashes[0]) + require.NoError(h, err, "failed to get mined block") + + blocks = append(blocks, block) + + // Ensure all wallets we created in this test have caught up. + for _, w := range h.ActiveWallets() { + h.AssertWalletSynced(w) + } + } + + return blocks +} + +// getRawMempool returns the miner's mempool transaction ids. +func (h *HarnessTest) getRawMempool() ([]chainhash.Hash, error) { + h.Helper() + + txids, err := h.miner.Client.GetRawMempool() + if err != nil { + return nil, fmt.Errorf("get raw mempool: %w", err) + } + + result := make([]chainhash.Hash, 0, len(txids)) + for _, txid := range txids { + if txid == nil { + continue + } + + result = append(result, *txid) + } + + return result, nil +} + +// blockHasNoTxns returns an error if block contains non-coinbase transactions. +func (h *HarnessTest) blockHasNoTxns(block *wire.MsgBlock) error { + h.Helper() + + if block == nil { + return ErrNilBlock + } + + if len(block.Transactions) <= 1 { + return nil + } + + var desc strings.Builder + desc.WriteString(fmt.Sprintf( + "block %v has %d txns:\n", + block.BlockHash(), len(block.Transactions)-1, + )) + + for _, tx := range block.Transactions[1:] { + if tx == nil { + continue + } + + desc.WriteString(fmt.Sprintf("%v\n", tx.TxHash())) + } + + desc.WriteString( + "Consider using `MineBlocksAndAssertNumTxns` if you expect " + + "txns, or `MineEmptyBlocks` if you want to keep txns " + + "unconfirmed.", + ) + + return fmt.Errorf("%w: %s", ErrBlockUnexpectedTxns, desc.String()) +} + +// SendOutput sends funds from the miner. +func (h *HarnessTest) SendOutput(output *wire.TxOut, + feeRate btcutil.Amount) *chainhash.Hash { + + h.Helper() + + txid, err := h.miner.SendOutputs([]*wire.TxOut{output}, feeRate) + require.NoError(h, err, "failed to send output") + + return txid +} + +// GetBestBlock returns the hash and height of the best block. +func (h *HarnessTest) GetBestBlock() (*chainhash.Hash, int32) { + h.Helper() + + hash, height, err := h.miner.Client.GetBestBlock() + require.NoError(h, err, "failed to get best block") + + return hash, height +} diff --git a/bwtest/harness_wallet.go b/bwtest/harness_wallet.go new file mode 100644 index 0000000000..9245ac635c --- /dev/null +++ b/bwtest/harness_wallet.go @@ -0,0 +1,126 @@ +package bwtest + +import ( + "context" + "strings" + "time" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/wallet" + "github.com/stretchr/testify/require" +) + +const ( + // defaultPubPass is the standard public passphrase used by test wallets. + defaultPubPass = "public" + + // defaultPrivPass is the standard private passphrase used by test wallets. + defaultPrivPass = "private" + + // defaultWalletRecoveryWindow keeps enough look-ahead addresses for test + // cases that derive multiple addresses while scanning historical blocks. + defaultWalletRecoveryWindow = 20 + + // defaultWalletSyncRetryInterval controls how often wallet sync retries + // when the chain backend is temporarily unavailable during startup. + defaultWalletSyncRetryInterval = 500 * time.Millisecond +) + +// CreateEmptyWallet creates, starts, and registers a new wallet instance. +// +// This is intended for non-manager integration tests that want a ready-to-use +// wallet without repeating boilerplate. +func (h *HarnessTest) CreateEmptyWallet() *wallet.Wallet { + h.Helper() + + name := "itest-" + strings.ReplaceAll(h.Name(), "/", "_") + + cfg := wallet.Config{ + // Use the subtest-scoped DB and chain client prepared by the harness. + DB: h.WalletDB, + Chain: h.ChainClient, + + // Keep network and startup behavior deterministic across tests. + ChainParams: h.NetParams(), + RecoveryWindow: defaultWalletRecoveryWindow, + WalletSyncRetryInterval: defaultWalletSyncRetryInterval, + + // Use a unique wallet name per test to avoid collisions in logs. + Name: name, + PubPassphrase: []byte(defaultPubPass), + } + + params := wallet.CreateWalletParams{ + // Generate a fresh seed for each test wallet. + Mode: wallet.ModeGenSeed, + PubPassphrase: []byte(defaultPubPass), + PrivatePassphrase: []byte(defaultPrivPass), + + // Use an old birthday to ensure the wallet can discover historical + // blocks when used in tests that pre-mine chain state. + Birthday: time.Now().Add(-1 * time.Hour), + } + + manager := wallet.NewManager() + w, err := manager.Create(cfg, params) + require.NoError(h, err, "failed to create wallet") + + err = w.Start(h.Context()) + require.NoError(h, err, "failed to start wallet") + + h.Cleanup(func() { + // We use a background context here because the test context might be + // canceled by the time cleanup runs. + _ = w.Stop(context.Background()) + }) + + // Register the wallet so harness helpers can assert global invariants. + h.RegisterWallet(w) + + return w +} + +// CreateFundedWallet creates an empty wallet and funds it with 10 BTC. +// +// This is intended for future integration tests that need spendable funds. +func (h *HarnessTest) CreateFundedWallet() *wallet.Wallet { + h.Helper() + + w := h.CreateEmptyWallet() + + err := w.Unlock(h.Context(), wallet.UnlockRequest{ + Passphrase: []byte(defaultPrivPass), + }) + require.NoError(h, err, "failed to unlock wallet") + + addr, err := w.NewAddress( + h.Context(), waddrmgr.DefaultAccountName, + waddrmgr.WitnessPubKey, false, + ) + require.NoError(h, err, "failed to create address") + + pkScript, err := txscript.PayToAddrScript(addr) + require.NoError(h, err, "failed to create pkscript") + + const tenBTC = 10 * btcutil.SatoshiPerBitcoin + + output := &wire.TxOut{Value: int64(tenBTC), PkScript: pkScript} + + // Use a minimal fee rate for regtest. + h.SendOutput(output, btcutil.Amount(1)) + + // Confirm and wait for sync. + h.MineBlocks(1) + h.AssertWalletSynced(w) + + return w +} + +func init() { + // Use fast scrypt options for tests to avoid CPU exhaustion and + // timeouts, especially when running with -race. + waddrmgr.DefaultScryptOptions = waddrmgr.FastScryptOptions +} diff --git a/bwtest/logdir.go b/bwtest/logdir.go new file mode 100644 index 0000000000..17c7a72fdf --- /dev/null +++ b/bwtest/logdir.go @@ -0,0 +1,158 @@ +package bwtest + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +const ( + // testLogsRootDir is the directory under the itest package where all + // per-run logs are stored. + // + // Note: When running the integration tests with `go test ./itest`, the + // working directory is `itest`, so logs are written under + // `itest/test-logs`. + testLogsRootDir = "test-logs" + + maxLogDirAttempts = 1000 +) + +// createTestLogDir creates a per-run log directory under `test-logs`. +// +// The directory is named using the format: +// +// log---YYYYMMDD-HHMMSS +// +// If the directory already exists, a numeric suffix is appended. +func createTestLogDir(t *testing.T, chainBackend, dbBackend string) string { + t.Helper() + + err := os.MkdirAll(testLogsRootDir, logDirPerm) + require.NoError(t, err, "unable to create test log root") + + chainBackend = sanitizeLogToken(chainBackend) + dbBackend = sanitizeLogToken(dbBackend) + + base := fmt.Sprintf( + "log-%s-%s-%s", chainBackend, dbBackend, + time.Now().Format("20060102-150405"), + ) + + // Use Mkdir instead of MkdirAll so we can detect collisions and retry + // with a deterministic numeric suffix. + for i := range maxLogDirAttempts { + dir := base + if i > 0 { + dir = fmt.Sprintf("%s-%d", base, i) + } + + fullPath := filepath.Join(testLogsRootDir, dir) + + err := os.Mkdir(fullPath, logDirPerm) + if err == nil { + _, _ = fmt.Fprintf(os.Stdout, "itest logs dir: %s\n", fullPath) + return fullPath + } + + if os.IsExist(err) { + continue + } + + require.NoError(t, err, "unable to create test log dir") + } + + t.Fatalf( + "unable to create test log dir: too many collisions (%d)", + maxLogDirAttempts, + ) + + return "" +} + +// sanitizeLogToken converts a string into a safe filename token. +func sanitizeLogToken(token string) string { + if token == "" { + return "unknown" + } + + var b strings.Builder + for _, r := range token { + if isSafeLogRune(r) { + b.WriteRune(r) + continue + } + + b.WriteByte('_') + } + + return b.String() +} + +// isSafeLogRune reports whether r can be used in log directory/file names +// without additional escaping. +func isSafeLogRune(r rune) bool { + switch { + case r >= 'a' && r <= 'z': + return true + case r >= 'A' && r <= 'Z': + return true + case r >= '0' && r <= '9': + return true + case r == '-' || r == '_': + return true + default: + return false + } +} + +// createOrEnsureLogSubDir creates a named sub-directory under a per-run log +// directory. +func createOrEnsureLogSubDir(t *testing.T, parent, name string) string { + t.Helper() + + full := filepath.Join(parent, name) + err := os.MkdirAll(full, logDirPerm) + require.NoError(t, err, "unable to create log subdir") + + return full +} + +// createUniqueLogSubDir creates a uniquely named sub-directory under a per-run +// log directory. +func createUniqueLogSubDir(t *testing.T, parent, prefix string) string { + t.Helper() + + // Retry with a numeric suffix when a directory name collision occurs. + for i := range maxLogDirAttempts { + dir := prefix + if i > 0 { + dir = fmt.Sprintf("%s-%d", prefix, i) + } + + full := filepath.Join(parent, dir) + + err := os.Mkdir(full, logDirPerm) + if err == nil { + return full + } + + if os.IsExist(err) { + continue + } + + require.NoError(t, err, "unable to create log subdir") + } + + t.Fatalf( + "unable to create log subdir: too many collisions (%d)", + maxLogDirAttempts, + ) + + return "" +} diff --git a/bwtest/logs_finalize.go b/bwtest/logs_finalize.go new file mode 100644 index 0000000000..6cb6d455fe --- /dev/null +++ b/bwtest/logs_finalize.go @@ -0,0 +1,263 @@ +package bwtest + +import ( + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "testing" +) + +const ( + minerLogFilename = "miner.log" + chainBackendLogFilename = "chain_backend.log" + + logFilePerm = 0o600 +) + +// finalizeLogs flattens component logs into the per-run log directory. +func (h *HarnessTest) finalizeLogs() { + h.Helper() + + // Flatten miner logs. + minerDst := filepath.Join(h.logDir, minerLogFilename) + + err := flattenBtcdLogs(h.T, h.miner.logPath, minerDst) + if err != nil { + h.Logf("failed to flatten miner logs: %v", err) + } + + chainLogDir := h.Backend.LogDir() + + chainDst := filepath.Join(h.logDir, chainBackendLogFilename) + if chainLogDir == "" { + // Some backends (eg. neutrino) do not have an external process log + // directory. Still create the file for consistent log collection. + // #nosec G304 -- chainDst is created by the test harness. + f, err := os.OpenFile( + chainDst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, logFilePerm, + ) + if err != nil { + h.Logf("failed to create chain backend log file: %v", err) + return + } + + _ = f.Close() + + return + } + + switch h.Backend.Name() { + case backendBtcd: + err = flattenBtcdLogs(h.T, chainLogDir, chainDst) + if err != nil { + h.Logf("failed to flatten btcd backend logs: %v", err) + } + + case backendBitcoind: + err = flattenBitcoindLogs(h.T, chainLogDir, chainDst) + if err != nil { + h.Logf("failed to flatten bitcoind backend logs: %v", err) + } + + default: + // No backend logs to flatten. + } +} + +// flattenBitcoindLogs concatenates bitcoind logs under srcDir into dstFile. +func flattenBitcoindLogs(t *testing.T, srcDir, dstFile string) error { + t.Helper() + + // Capture process stdout/stderr first, as fatal startup errors might not be + // present in debug.log. + prelude := []string{ + filepath.Join(srcDir, "bitcoind.stderr.log"), + filepath.Join(srcDir, "bitcoind.stdout.log"), + } + + pattern := filepath.Join(srcDir, "*", "debug.log*") + + matches, err := filepath.Glob(pattern) + if err != nil { + return fmt.Errorf("glob bitcoind logs: %w", err) + } + + files := make([]string, 0, len(prelude)+len(matches)) + files = append(files, prelude...) + files = append(files, matches...) + + files = filterRegularFiles(files) + if len(files) == 0 { + return nil + } + + // bitcoind rotates debug.log.1, debug.log.2 etc but we don't try too hard + // ordering here. + sort.Strings(files) + + // #nosec G304 -- dstFile is created by the test harness. + f, err := os.OpenFile(dstFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, + logFilePerm) + if err != nil { + return fmt.Errorf("open dst log: %w", err) + } + + defer func() { + _ = f.Close() + }() + + for i, p := range files { + // Keep a blank line between concatenated source files to make the + // merged output easier to scan when debugging CI failures. + if i > 0 { + _, _ = f.WriteString("\n") + } + + base := filepath.Base(p) + _, _ = f.WriteString("--- " + base + " ---\n") + + // #nosec G304 -- p is discovered under the harness-controlled log dir. + src, err := os.Open(p) + if err != nil { + return fmt.Errorf("open src log: %w", err) + } + + _, cpErr := io.Copy(f, src) + _ = src.Close() + + if cpErr != nil { + return fmt.Errorf("copy src log: %w", cpErr) + } + } + + _ = os.RemoveAll(srcDir) + + return nil +} + +// flattenBtcdLogs concatenates btcd logs under srcDir into dstFile. +func flattenBtcdLogs(t *testing.T, srcDir, dstFile string) error { + t.Helper() + + pattern := filepath.Join(srcDir, "*", "btcd.log*") + + matches, err := filepath.Glob(pattern) + if err != nil { + return fmt.Errorf("glob btcd logs: %w", err) + } + + if len(matches) == 0 { + return nil + } + + files := filterRegularFiles(matches) + if len(files) == 0 { + return nil + } + + sortBtcdLogs(files) + + // #nosec G304 -- dstFile is created by the test harness. + f, err := os.OpenFile(dstFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, + logFilePerm) + if err != nil { + return fmt.Errorf("open dst log: %w", err) + } + + defer func() { + _ = f.Close() + }() + + for i, p := range files { + // Add a small delimiter between rotated files. + if i > 0 { + _, _ = f.WriteString("\n") + } + + base := filepath.Base(p) + _, _ = f.WriteString("--- " + base + " ---\n") + + // #nosec G304 -- p is discovered under the harness-controlled log dir. + src, err := os.Open(p) + if err != nil { + return fmt.Errorf("open src log: %w", err) + } + + _, cpErr := io.Copy(f, src) + _ = src.Close() + + if cpErr != nil { + return fmt.Errorf("copy src log: %w", cpErr) + } + } + + // Best effort cleanup to keep the log dir shallow. + _ = os.RemoveAll(srcDir) + + return nil +} + +// filterRegularFiles filters to existing regular files. +func filterRegularFiles(paths []string) []string { + files := make([]string, 0, len(paths)) + for _, p := range paths { + info, err := os.Stat(p) + if err != nil { + continue + } + + if info.Mode().IsRegular() { + files = append(files, p) + } + } + + return files +} + +// sortBtcdLogs sorts btcd logs by rotation index so older logs appear first. +func sortBtcdLogs(paths []string) { + sort.Slice(paths, func(i, j int) bool { + iBase := filepath.Base(paths[i]) + jBase := filepath.Base(paths[j]) + + // Prefer older rotated logs first: btcd.log.N ... btcd.log.1 then + // btcd.log. + iN, iOk := btcdLogRotationIndex(iBase) + + jN, jOk := btcdLogRotationIndex(jBase) + if iOk && jOk { + // Larger rotation index means an older file. For example, + // btcd.log.3 is older than btcd.log.1 and should be concatenated + // first. + return iN > jN + } + + if iOk != jOk { + // Rotated logs before the active log. + return iOk + } + + // Fallback to lexicographic ordering. + return iBase < jBase + }) +} + +// btcdLogRotationIndex parses the rotation suffix of btcd log filenames. +func btcdLogRotationIndex(base string) (int, bool) { + // btcd rotates logs like btcd.log.1, btcd.log.2, ... + const prefix = "btcd.log." + if !strings.HasPrefix(base, prefix) { + return 0, false + } + + n, err := strconv.Atoi(strings.TrimPrefix(base, prefix)) + if err != nil { + return 0, false + } + + return n, true +} diff --git a/bwtest/miner.go b/bwtest/miner.go new file mode 100644 index 0000000000..872bd68de9 --- /dev/null +++ b/bwtest/miner.go @@ -0,0 +1,134 @@ +package bwtest + +import ( + "testing" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/integration/rpctest" + "github.com/btcsuite/btcd/rpcclient" + "github.com/stretchr/testify/require" +) + +const ( + // minerSetupOutputs is the number of outputs to generate during miner + // setup. + minerSetupOutputs = 50 + + // minMatureBlocks is the minimum number of blocks to mine to ensure + // coinbase maturity. + minMatureBlocks = 100 + + // retryMultiplier is the multiplier for connection retries to make tests + // more robust. + retryMultiplier = 2 + + // minerWindowMultiplier is the multiplier for the miner confirmation + // window to ensure we mine enough blocks for activation. + minerWindowMultiplier = 2 +) + +var ( + // harnessNetParams is the network parameters used for the harness. + harnessNetParams = &chaincfg.RegressionNetParams +) + +// minerHarness is a wrapper around rpctest.Harness that provides a mining node +// for integration tests. +type minerHarness struct { + *testing.T + + *rpctest.Harness + + // logPath is the directory path of the miner's logs. + logPath string +} + +// newMiner creates a new minerHarness instance. +func newMiner(t *testing.T, logDir string) *minerHarness { + t.Helper() + + btcdBinary, err := GetBtcdBinary() + require.NoError(t, err, "unable to find btcd binary") + + err = ensureLogDir(logDir) + require.NoError(t, err, "unable to create miner log dir") + + args := []string{ + "--rejectnonstd", // Reject non-standard txs in tests. + "--txindex", // Required for some RPC queries. + "--nowinservice", // Avoid Windows service integration. + "--nobanning", // Avoid peer banning in local tests. + "--debuglevel=debug", // Provide detailed logs for debugging. + "--logdir=" + logDir, // Write logs into our per-run dir. + "--trickleinterval=100ms", // Speed up inv relay in regtest. + "--nostalldetect", // Avoid stall detection flakiness. + } + + // We use an empty handlers struct as we don't need to handle notifications + // directly in the miner wrapper for now. + handlers := &rpcclient.NotificationHandlers{} + + harness, err := rpctest.New(harnessNetParams, handlers, args, btcdBinary) + require.NoError(t, err, "unable to create rpctest harness") + + m := &minerHarness{ + T: t, + Harness: harness, + logPath: logDir, + } + + return m +} + +// SetUp starts the miner node and generates initial blocks to activate SegWit. +func (m *minerHarness) SetUp() { + m.Helper() + + // Increase connection retries to make tests more robust. + m.MaxConnRetries = rpctest.DefaultMaxConnectionRetries * retryMultiplier + m.ConnectionRetryTimeout = rpctest.DefaultConnectionRetryTimeout * + retryMultiplier + + require.NoError( + m, m.Harness.SetUp(true, minerSetupOutputs), + "unable to setup miner", + ) + + // Mine enough blocks to activate SegWit. + // MinerConfirmationWindow is usually 144 for mainnet, but likely smaller + // for regtest. For rpctest, standard is often to mine ~200 blocks + // total to ensure maturity and activation. Assuming harness params are + // standard regtest. + numBlocks := max( + harnessNetParams.MinerConfirmationWindow*minerWindowMultiplier, + minMatureBlocks, + ) + + _, err := m.Client.Generate(numBlocks) + require.NoError(m, err, "unable to generate initial blocks") +} + +// SetUpNoChain starts the miner node without generating a test chain. +// +// This is intended for scenarios where the miner will sync to an existing +// chain (for example, when spawning a temporary miner for reorg tests). +func (m *minerHarness) SetUpNoChain() { + m.Helper() + + // Increase connection retries to make tests more robust. + m.MaxConnRetries = rpctest.DefaultMaxConnectionRetries * retryMultiplier + m.ConnectionRetryTimeout = rpctest.DefaultConnectionRetryTimeout * + retryMultiplier + + // SetUp(true, 0) starts the node, sets up the in-memory wallet, and + // registers notifications, but does not mine any blocks. + require.NoError( + m, m.Harness.SetUp(true, 0), + "unable to setup miner", + ) +} + +// Stop shuts down the miner. +func (m *minerHarness) Stop() { + require.NoError(m, m.TearDown(), "tear down miner failed") +} diff --git a/bwtest/neutrino.go b/bwtest/neutrino.go new file mode 100644 index 0000000000..5a81e24bdc --- /dev/null +++ b/bwtest/neutrino.go @@ -0,0 +1,119 @@ +package bwtest + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/btcsuite/btcwallet/chain" + "github.com/btcsuite/btcwallet/walletdb" + _ "github.com/btcsuite/btcwallet/walletdb/bdb" // Register bdb walletdb driver. + "github.com/lightninglabs/neutrino" +) + +const neutrinoDBTimeout = 5 * time.Second + +// NeutrinoBackend is a ChainBackend that uses an in-process neutrino chain +// service connected to the shared miner. +type NeutrinoBackend struct { + minerAddr string +} + +// NewNeutrinoBackend creates a new NeutrinoBackend. +// +// Neutrino is an in-process backend and does not write process logs into the +// passed logDir. +func NewNeutrinoBackend(t *testing.T, _ string) *NeutrinoBackend { + t.Helper() + + return &NeutrinoBackend{} +} + +// Name returns the identifier of the backend. +func (n *NeutrinoBackend) Name() string { + return backendNeutrino +} + +// Start is a no-op for neutrino. +func (n *NeutrinoBackend) Start() error { + return nil +} + +// Stop is a no-op for neutrino. +func (n *NeutrinoBackend) Stop() error { + return nil +} + +// ConnectMiner records the miner address for later use. +func (n *NeutrinoBackend) ConnectMiner(minerAddr string) error { + n.minerAddr = minerAddr + return nil +} + +// NewChainClient creates a new neutrino-backed chain.Interface connected to +// the shared miner. +func (n *NeutrinoBackend) NewChainClient(ctx context.Context) (chain.Interface, + func(), error) { + + if n.minerAddr == "" { + return nil, nil, fmt.Errorf("neutrino: %w", errMissingMinerAddr) + } + + dataDir, err := os.MkdirTemp("", "btcwallet-neutrino-") + if err != nil { + return nil, nil, fmt.Errorf("create neutrino temp dir: %w", err) + } + + spvdb, err := walletdb.Create( + "bdb", filepath.Join(dataDir, "neutrino.db"), true, + neutrinoDBTimeout, false, + ) + if err != nil { + _ = os.RemoveAll(dataDir) + return nil, nil, fmt.Errorf("create neutrino db: %w", err) + } + + chainService, err := neutrino.NewChainService(neutrino.Config{ + DataDir: dataDir, + Database: spvdb, + ChainParams: *harnessNetParams, + ConnectPeers: []string{n.minerAddr}, + }) + if err != nil { + _ = spvdb.Close() + _ = os.RemoveAll(dataDir) + + return nil, nil, fmt.Errorf("create neutrino chain service: %w", err) + } + + client := chain.NewNeutrinoClient(harnessNetParams, chainService) + + err = client.Start(ctx) + if err != nil { + _ = spvdb.Close() + _ = os.RemoveAll(dataDir) + + return nil, nil, fmt.Errorf("start neutrino client: %w", err) + } + + cleanup := func() { + client.Stop() + client.WaitForShutdown() + + _ = spvdb.Close() + _ = os.RemoveAll(dataDir) + } + + return client, cleanup, nil +} + +// LogDir returns an empty string because neutrino has no backend daemon. +func (n *NeutrinoBackend) LogDir() string { + // Neutrino runs in-process, so there is no backend daemon log directory. + return "" +} + +var _ ChainBackend = (*NeutrinoBackend)(nil) diff --git a/bwtest/perms.go b/bwtest/perms.go new file mode 100644 index 0000000000..3db2c9ffc7 --- /dev/null +++ b/bwtest/perms.go @@ -0,0 +1,7 @@ +package bwtest + +// logDirPerm is the default permission for harness-managed log directories. +// +// 0o750 keeps logs accessible to the current user/group while avoiding +// world-readable test artifacts that may contain sensitive runtime details. +const logDirPerm = 0o750 diff --git a/bwtest/rlimit_other.go b/bwtest/rlimit_other.go new file mode 100644 index 0000000000..adc8986216 --- /dev/null +++ b/bwtest/rlimit_other.go @@ -0,0 +1,11 @@ +//go:build !(darwin || linux) + +package bwtest + +// raiseNoFileLimit attempts to increase the current process file descriptor +// limit. +// +// On platforms where this isn't supported, this is a no-op. +func raiseNoFileLimit() error { + return nil +} diff --git a/bwtest/rlimit_unix.go b/bwtest/rlimit_unix.go new file mode 100644 index 0000000000..ed0702ffea --- /dev/null +++ b/bwtest/rlimit_unix.go @@ -0,0 +1,83 @@ +//go:build darwin || linux + +package bwtest + +import ( + "fmt" + "syscall" +) + +const ( + // desiredNoFileLimit is the target soft descriptor limit for test runs that + // launch bitcoind. + desiredNoFileLimit = 4096 + + // assumedInfinityThreshold is the cutoff used to treat RLIMIT_NOFILE values + // as effectively infinite and normalize them to a finite limit. + assumedInfinityThreshold = 1 << 60 +) + +// raiseNoFileLimit attempts to increase the current process file descriptor +// limit. +// +// This is a best-effort helper intended for integration tests that launch +// external processes like bitcoind. Some systems have a low default soft limit, +// which can cause bitcoind to fail during startup. +// +// This helper is still needed because bitcoind validates RLIMIT_NOFILE on +// startup. Normalizing extreme/low values keeps CI environments predictable. +func raiseNoFileLimit() error { + var rlim syscall.Rlimit + + err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rlim) + if err != nil { + return fmt.Errorf("get rlimit: %w", err) + } + + newCur, ok := desiredNoFileCur(rlim) + if !ok { + return nil + } + + rlim.Cur = newCur + + err = syscall.Setrlimit(syscall.RLIMIT_NOFILE, &rlim) + if err != nil { + return fmt.Errorf("set rlimit: %w", err) + } + + return nil +} + +// desiredNoFileCur computes the desired RLIMIT_NOFILE soft value and reports +// whether Setrlimit should be called. +func desiredNoFileCur(rlim syscall.Rlimit) (uint64, bool) { + // Some environments report RLIMIT_NOFILE as effectively infinite. Bitcoind + // fails fast if the value cannot be represented correctly. Normalizing this + // to a finite limit avoids startup failures. + if rlim.Cur >= assumedInfinityThreshold { + newCur := uint64(desiredNoFileLimit) + if rlim.Max > 0 && newCur > rlim.Max { + newCur = rlim.Max + } + + return newCur, true + } + + // Nothing to do if we're already above our desired limit. + if rlim.Cur >= desiredNoFileLimit { + return 0, false + } + + // Increase the soft limit, but don't exceed the hard limit. + newCur := uint64(desiredNoFileLimit) + if rlim.Max > 0 && newCur > rlim.Max { + newCur = rlim.Max + } + + if newCur <= rlim.Cur { + return 0, false + } + + return newCur, true +} diff --git a/bwtest/tcp.go b/bwtest/tcp.go new file mode 100644 index 0000000000..76d534e6be --- /dev/null +++ b/bwtest/tcp.go @@ -0,0 +1,41 @@ +package bwtest + +import ( + "context" + "fmt" + "net" + "time" + + "github.com/btcsuite/btcwallet/bwtest/wait" +) + +// waitForTCPListener polls until addr accepts TCP connections. +// +// The integration harness uses this before starting external backends that +// immediately dial the miner. Without the extra readiness check, a backend's +// first outbound peer attempt can race the rpctest miner listener on slower CI +// runners and leave the backend stuck at height 0 until its next reconnect. +func waitForTCPListener(addr string, timeout time.Duration) error { + err := wait.NoError(func() error { + ctx, cancel := context.WithTimeout( + context.Background(), wait.PollInterval, + ) + defer cancel() + + dialer := &net.Dialer{} + + conn, err := dialer.DialContext(ctx, "tcp", addr) + if err != nil { + return fmt.Errorf("dial %s: %w", addr, err) + } + + _ = conn.Close() + + return nil + }, timeout) + if err != nil { + return fmt.Errorf("wait for tcp listener %s: %w", addr, err) + } + + return nil +} diff --git a/bwtest/timeouts.go b/bwtest/timeouts.go new file mode 100644 index 0000000000..6f348a8935 --- /dev/null +++ b/bwtest/timeouts.go @@ -0,0 +1,9 @@ +package bwtest + +import "time" + +const ( + // defaultTestTimeout is a shared default timeout for polling and setup + // steps in integration tests. + defaultTestTimeout = 30 * time.Second +) diff --git a/bwtest/utils.go b/bwtest/utils.go new file mode 100644 index 0000000000..7a5e0485a3 --- /dev/null +++ b/bwtest/utils.go @@ -0,0 +1,50 @@ +package bwtest + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" +) + +// GetBtcdBinary returns the path to the btcd binary. +// It checks if "btcd" is in the PATH. +func GetBtcdBinary() (string, error) { + // If specific path is needed, we could check env vars here. + path, err := exec.LookPath("btcd") + if err != nil { + return "", fmt.Errorf("failed to find btcd binary: %w", err) + } + + path, err = filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("failed to get absolute path: %w", err) + } + + return path, nil +} + +// GetBitcoindBinary returns the path to the bitcoind binary. +func GetBitcoindBinary() (string, error) { + path, err := exec.LookPath("bitcoind") + if err != nil { + return "", fmt.Errorf("failed to find bitcoind binary: %w", err) + } + + path, err = filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("failed to get absolute path: %w", err) + } + + return path, nil +} + +// ensureLogDir creates the log directory if it doesn't exist. +func ensureLogDir(dir string) error { + err := os.MkdirAll(dir, logDirPerm) + if err != nil { + return fmt.Errorf("mkdir log dir: %w", err) + } + + return nil +} diff --git a/bwtest/wait/wait.go b/bwtest/wait/wait.go new file mode 100644 index 0000000000..fd41a5ebae --- /dev/null +++ b/bwtest/wait/wait.go @@ -0,0 +1,58 @@ +// Package wait provides polling helpers for integration tests. +package wait + +import ( + "errors" + "time" +) + +var ( + // ErrNoResponse is returned when f does not return within the timeout. + ErrNoResponse = errors.New("method did not return within the timeout") +) + +// PollInterval is the default polling interval used by NoError. +const PollInterval = 200 * time.Millisecond + +// NoError polls f until it returns nil or the timeout is reached. +// +// If the timeout is reached, the last error returned by f is returned. +func NoError(f func() error, timeout time.Duration) error { + // f is expected to be cheap and non-blocking. This helper is intended for + // polling state (e.g. "is the node ready?") rather than performing a long + // operation. + // + // NOTE: NoError does not interrupt f. If f blocks, NoError may block longer + // than the provided timeout. + + deadline := time.NewTimer(timeout) + defer deadline.Stop() + + ticker := time.NewTicker(PollInterval) + defer ticker.Stop() + + // Call f() immediately to avoid the initial ticker delay. + lastErr := f() + if lastErr == nil { + return nil + } + + for { + select { + case <-deadline.C: + if lastErr == nil { + return ErrNoResponse + } + + return lastErr + + case <-ticker.C: + err := f() + if err == nil { + return nil + } + + lastErr = err + } + } +} diff --git a/bwtest/wallet_logging.go b/bwtest/wallet_logging.go new file mode 100644 index 0000000000..a623644402 --- /dev/null +++ b/bwtest/wallet_logging.go @@ -0,0 +1,78 @@ +package bwtest + +import ( + "fmt" + "os" + "strings" + "testing" + + "github.com/btcsuite/btcd/rpcclient" + "github.com/btcsuite/btclog" + "github.com/btcsuite/btcwallet/chain" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/wallet" + "github.com/btcsuite/btcwallet/wtxmgr" + "github.com/stretchr/testify/require" +) + +// walletLogFilePerm is intentionally more restrictive than logDirPerm because +// wallet logs may contain addresses, txids, and operational details that should +// not be readable by other users on a shared machine. +const walletLogFilePerm = 0o600 + +// setUpWalletLogging configures the btclog-based loggers used by btcwallet to +// write into the provided log file path. +// +// NOTE: This is package-global logger configuration. It should only be used in +// serial integration tests. +func setUpWalletLogging(t *testing.T, logPath string) func() { + t.Helper() + + // #nosec G304 -- logPath is created by the test harness. + f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, + walletLogFilePerm) + require.NoError(t, err, "unable to create wallet log file") + + backend := btclog.NewBackend(f) + + btwl := backend.Logger("BTWL") + amgr := backend.Logger("AMGR") + tmgr := backend.Logger("TMGR") + chio := backend.Logger("CHIO") + rpcl := backend.Logger("RPCC") + + level, _ := btclog.LevelFromString("debug") + btwl.SetLevel(level) + amgr.SetLevel(level) + tmgr.SetLevel(level) + chio.SetLevel(level) + rpcl.SetLevel(level) + + wallet.UseLogger(btwl) + waddrmgr.UseLogger(amgr) + wtxmgr.UseLogger(tmgr) + chain.UseLogger(chio) + rpcclient.UseLogger(rpcl) + + return func() { + _ = f.Sync() + _ = f.Close() + } +} + +// walletLogFileName returns the per-test wallet log filename. +func walletLogFileName(t *testing.T) string { + t.Helper() + + // Use the leaf subtest name to keep filenames short. + name := t.Name() + + parts := strings.Split(name, "/") + if len(parts) > 0 { + name = parts[len(parts)-1] + } + + name = sanitizeLogToken(name) + + return fmt.Sprintf("wallet-%s.log", name) +} diff --git a/chain/bitcoind_client.go b/chain/bitcoind_client.go index 3136d4c233..25b70bd1ff 100644 --- a/chain/bitcoind_client.go +++ b/chain/bitcoind_client.go @@ -4,6 +4,7 @@ import ( "container/list" "context" "encoding/hex" + "encoding/json" "errors" "fmt" "sync" @@ -12,7 +13,10 @@ import ( "github.com/btcsuite/btcd/btcjson" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/gcs" + "github.com/btcsuite/btcd/btcutil/gcs/builder" "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/rpcclient" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" @@ -25,6 +29,10 @@ var ( // to receive a notification for a specific item and the bitcoind client // is in the middle of shutting down. ErrBitcoindClientShuttingDown = errors.New("client is shutting down") + + // ErrOnlyBasicFilters is an error returned when a filter type other + // than basic is requested. + ErrOnlyBasicFilters = errors.New("only basic filters are supported") ) // BitcoindClient represents a persistent client connection to a bitcoind server @@ -49,6 +57,15 @@ type BitcoindClient struct { // the RPC and ZMQ connections to a bitcoind node. chainConn *BitcoindConn + // batchClient is a secondary RPC client dedicated for batch requests. + // This client is created specifically for batch operations because the + // rpcclient.Client in batch mode is stateful, accumulating requests + // until `Send()` is called. Using a dedicated instance avoids race + // conditions and ensures isolation from other concurrent RPC calls + // made by the main `chainConn.client` or other `BitcoindClient` + // instances. + batchClient *rpcclient.Client + // bestBlock keeps track of the tip of the current best chain. bestBlockMtx sync.RWMutex bestBlock waddrmgr.BlockStamp @@ -113,6 +130,56 @@ func (c *BitcoindClient) BackEnd() string { return "bitcoind" } +// GetCFilter returns a compact filter for the given block hash and filter +// type. +// +// NOTE: This is part of the chain.Interface interface. +func (c *BitcoindClient) GetCFilter(hash *chainhash.Hash, + filterType wire.FilterType) (*gcs.Filter, error) { + + if filterType != wire.GCSFilterRegular { + return nil, ErrOnlyBasicFilters + } + + // The getblockfilter RPC takes the block hash and the filter type. + // Filter type defaults to "basic" if omitted, but we specify it for + // clarity. + params := []json.RawMessage{ + json.RawMessage(fmt.Sprintf("%q", hash.String())), + json.RawMessage(fmt.Sprintf("%q", "basic")), + } + + resp, err := c.chainConn.client.RawRequest("getblockfilter", params) + if err != nil { + return nil, c.MapRPCErr(err) + } + + var res struct { + Filter string `json:"filter"` + Header string `json:"header"` + } + + err = json.Unmarshal(resp, &res) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal filter: %w", err) + } + + filterBytes, err := hex.DecodeString(res.Filter) + if err != nil { + return nil, fmt.Errorf("failed to decode filter: %w", err) + } + + filter, err := gcs.FromNBytes( + builder.DefaultP, builder.DefaultM, filterBytes, + ) + if err != nil { + return nil, fmt.Errorf("failed to create filter from bytes: %w", + err) + } + + return filter, nil +} + // GetBestBlock returns the highest block known to bitcoind. func (c *BitcoindClient) GetBestBlock() (*chainhash.Hash, int32, error) { bcinfo, err := c.chainConn.client.GetBlockChainInfo() @@ -547,6 +614,9 @@ func (c *BitcoindClient) Stop() { // prevent sending notifications to it after it's been stopped. c.chainConn.RemoveClient(c.id) + c.batchClient.Shutdown() + c.batchClient.WaitForShutdown() + c.notificationQueue.Stop() } @@ -1159,8 +1229,13 @@ func (c *BitcoindClient) filterBlock(block *wire.MsgBlock, height int32, // transaction. blockDetails.Index = i txDetails := btcutil.NewTx(tx) + + // We disable individual transaction notifications here because + // the full set of relevant transactions will be dispatched + // atomically via FilteredBlockConnected at the end of block + // processing. isRelevant, rec, err := c.filterTx( - txDetails, blockDetails, notify, + txDetails, blockDetails, false, ) if err != nil { log.Warnf("Unable to filter transaction %v: %v", @@ -1323,8 +1398,9 @@ func (c *BitcoindClient) filterTx(txDetails *btcutil.Tx, c.mempool[*txDetails.Hash()] = struct{}{} } - c.onRelevantTx(rec, blockDetails) - + if notify { + c.onRelevantTx(rec, blockDetails) + } return true, rec, nil } @@ -1390,3 +1466,157 @@ func (c *BitcoindClient) updateWatchedFilters(update any) { } } } + +// GetBlockHashes returns a slice of block hashes for the given height range. +func (c *BitcoindClient) GetBlockHashes(startHeight, + endHeight int64) ([]chainhash.Hash, error) { + + if startHeight > endHeight { + return nil, fmt.Errorf("%w: start height %d, end height %d", + ErrInvalidParam, startHeight, endHeight) + } + + client := c.batchClient + count := endHeight - startHeight + 1 + hashes := make([]chainhash.Hash, 0, count) + futures := make([]rpcclient.FutureGetBlockHashResult, 0, count) + + for h := startHeight; h <= endHeight; h++ { + futures = append(futures, client.GetBlockHashAsync(h)) + } + + err := client.Send() + if err != nil { + return nil, fmt.Errorf("batch send: %w", err) + } + + for _, f := range futures { + hash, err := f.Receive() + if err != nil { + return nil, fmt.Errorf("receive block hash: %w", err) + } + + hashes = append(hashes, *hash) + } + + return hashes, nil +} + +// GetCFilters returns a slice of filters for the given block hashes. +func (c *BitcoindClient) GetCFilters(hashes []chainhash.Hash, + filterType wire.FilterType) ([]*gcs.Filter, error) { + + if filterType != wire.GCSFilterRegular { + return nil, ErrOnlyBasicFilters + } + + client := c.batchClient + filters := make([]*gcs.Filter, 0, len(hashes)) + futures := make([]rpcclient.FutureRawResult, 0, len(hashes)) + + for _, hash := range hashes { + params := []json.RawMessage{ + json.RawMessage(fmt.Sprintf("%q", hash.String())), + json.RawMessage(fmt.Sprintf("%q", "basic")), + } + futures = append(futures, client.RawRequestAsync( + "getblockfilter", params, + )) + } + + err := client.Send() + if err != nil { + return nil, fmt.Errorf("batch send: %w", err) + } + + for _, f := range futures { + resp, err := f.Receive() + if err != nil { + return nil, fmt.Errorf("receive cfilter: %w", err) + } + + var res struct { + Filter string `json:"filter"` + Header string `json:"header"` + } + + err = json.Unmarshal(resp, &res) + if err != nil { + return nil, fmt.Errorf("unmarshal cfilter: %w", err) + } + + filterBytes, err := hex.DecodeString(res.Filter) + if err != nil { + return nil, fmt.Errorf("decode cfilter: %w", err) + } + + filter, err := gcs.FromNBytes( + builder.DefaultP, builder.DefaultM, filterBytes, + ) + if err != nil { + return nil, fmt.Errorf("parse cfilter: %w", err) + } + + filters = append(filters, filter) + } + + return filters, nil +} + +// GetBlocks returns a slice of full blocks for the given block hashes. +func (c *BitcoindClient) GetBlocks(hashes []chainhash.Hash) ( + []*wire.MsgBlock, error) { + + client := c.batchClient + blocks := make([]*wire.MsgBlock, 0, len(hashes)) + futures := make([]rpcclient.FutureGetBlockResult, 0, len(hashes)) + + for _, hash := range hashes { + futures = append(futures, client.GetBlockAsync(&hash)) + } + + err := client.Send() + if err != nil { + return nil, fmt.Errorf("batch send: %w", err) + } + + for _, f := range futures { + block, err := f.Receive() + if err != nil { + return nil, fmt.Errorf("receive block: %w", err) + } + + blocks = append(blocks, block) + } + + return blocks, nil +} + +// GetBlockHeaders returns a slice of block headers for the given block hashes. +func (c *BitcoindClient) GetBlockHeaders(hashes []chainhash.Hash) ( + []*wire.BlockHeader, error) { + + client := c.batchClient + headers := make([]*wire.BlockHeader, 0, len(hashes)) + futures := make([]rpcclient.FutureGetBlockHeaderResult, 0, len(hashes)) + + for _, hash := range hashes { + futures = append(futures, client.GetBlockHeaderAsync(&hash)) + } + + err := client.Send() + if err != nil { + return nil, fmt.Errorf("batch send: %w", err) + } + + for _, f := range futures { + header, err := f.Receive() + if err != nil { + return nil, fmt.Errorf("receive header: %w", err) + } + + headers = append(headers, header) + } + + return headers, nil +} diff --git a/chain/bitcoind_conn.go b/chain/bitcoind_conn.go index a03da9242e..12b0459653 100644 --- a/chain/bitcoind_conn.go +++ b/chain/bitcoind_conn.go @@ -401,13 +401,29 @@ func getCurrentNet(client *rpcclient.Client) (wire.BitcoinNet, error) { // NewBitcoindClient returns a bitcoind client using the current bitcoind // connection. This allows us to share the same connection using multiple // clients. -func (c *BitcoindConn) NewBitcoindClient() *BitcoindClient { +func (c *BitcoindConn) NewBitcoindClient() (*BitcoindClient, error) { + clientCfg := &rpcclient.ConnConfig{ + Host: c.cfg.Host, + User: c.cfg.User, + Pass: c.cfg.Pass, + DisableAutoReconnect: false, + DisableConnectOnNew: true, + DisableTLS: true, + HTTPPostMode: true, + } + + batchClient, err := rpcclient.NewBatch(clientCfg) + if err != nil { + return nil, fmt.Errorf("unable to create batch client: %w", err) + } + return &BitcoindClient{ quit: make(chan struct{}), id: atomic.AddUint64(&c.rescanClientCounter, 1), - chainConn: c, + chainConn: c, + batchClient: batchClient, watchedAddresses: make(map[string]struct{}), watchedOutPoints: make(map[wire.OutPoint]struct{}), @@ -419,7 +435,7 @@ func (c *BitcoindConn) NewBitcoindClient() *BitcoindClient { mempool: make(map[chainhash.Hash]struct{}), expiredMempool: make(map[int32]map[chainhash.Hash]struct{}), - } + }, nil } // AddClient adds a client to the set of active rescan clients of the current diff --git a/chain/bitcoind_events_test.go b/chain/bitcoind_events_test.go index aef18ea0c8..c623eff6c4 100644 --- a/chain/bitcoind_events_test.go +++ b/chain/bitcoind_events_test.go @@ -2,73 +2,110 @@ package chain import ( "fmt" - "math/rand" "os/exec" "testing" "time" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/gcs" + "github.com/btcsuite/btcd/btcutil/gcs/builder" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/integration/rpctest" "github.com/btcsuite/btcd/rpcclient" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/chain/port" "github.com/stretchr/testify/require" ) -// TestBitcoindEvents ensures that the BitcoindClient correctly delivers tx and -// block notifications for both the case where a ZMQ subscription is used and -// for the case where RPC polling is used. -func TestBitcoindEvents(t *testing.T) { +const ( + // defaultTestTimeout is the default timeout used for tests in this + // file. It is set to 30 seconds to allow for slow test environments. + defaultTestTimeout = 30 * time.Second +) + +// TestBitcoindEventsZMQ runs all bitcoind event tests using ZMQ subscriptions. +// +// We cannot run these tests in parallel as it involves running multiple +// bitcoind servers and btcd servers in the background. While running multiple +// bitcoind servers is fine, the current integration test setup in `btcd` +// doesn't allow it as the created RPC client will share the same ports. +// +//nolint:paralleltest +func TestBitcoindEventsZMQ(t *testing.T) { + runBitcoindEventsTests(t, false) +} + +// TestBitcoindEventsRPC runs all bitcoind event tests using RPC polling. +// +// We cannot run these tests in parallel as it involves running multiple +// bitcoind servers and btcd servers in the background. While running multiple +// bitcoind servers is fine, the current integration test setup in `btcd` +// doesn't allow it as the created RPC client will share the same ports. +// +//nolint:paralleltest +func TestBitcoindEventsRPC(t *testing.T) { + runBitcoindEventsTests(t, true) +} + +// runBitcoindEventsTests runs the suite of bitcoind event tests with the +// specified polling mode. +func runBitcoindEventsTests(t *testing.T, rpcPolling bool) { + t.Helper() + tests := []struct { - name string - rpcPolling bool + name string + testFn func(*testing.T, *rpctest.Harness, *BitcoindClient) }{ { - name: "Events via ZMQ subscriptions", - rpcPolling: false, + name: "Reorg", + testFn: testReorg, + }, + { + name: "NotifyBlocks", + testFn: testNotifyBlocks, + }, + { + name: "NotifyTx", + testFn: testNotifyTx, + }, + { + name: "NotifySpentMempool", + testFn: testNotifySpentMempool, }, { - name: "Events via RPC Polling", - rpcPolling: true, + name: "LookupInputMempoolSpend", + testFn: testLookupInputMempoolSpend, + }, + { + name: "GetCFilter", + testFn: testBitcoindClientGetCFilter, + }, + { + name: "Batch RPCs", + testFn: func(t *testing.T, h *rpctest.Harness, + bc *BitcoindClient) { + + t.Helper() + testInterfaceBatchMethods(t, h, bc) + }, }, } for _, test := range tests { test := test + t.Run(test.name, func(t *testing.T) { + // Initialize a fresh miner for the test case. + miner1 := setupMiner(t) + addr := miner1.P2PAddress() - // Set up 2 btcd miners. - miner1, miner2 := setupMiners(t) - addr := miner1.P2PAddress() + // Initialize a fresh bitcoind client for EVERY test + // case. + btcClient := setupBitcoind(t, addr, rpcPolling) - t.Run(test.name, func(t *testing.T) { - // Set up a bitcoind node and connect it to miner 1. - btcClient := setupBitcoind(t, addr, test.rpcPolling) - - // Test that the correct block `Connect` and - // `Disconnect` notifications are received during a - // re-org. - testReorg(t, miner1, miner2, btcClient) - - // Test that the expected block notifications are - // received. - btcClient = setupBitcoind(t, addr, test.rpcPolling) - testNotifyBlocks(t, miner1, btcClient) - - // Test that the expected tx notifications are - // received. - btcClient = setupBitcoind(t, addr, test.rpcPolling) - testNotifyTx(t, miner1, btcClient) - - // Test notifications for inputs already found in - // mempool. - btcClient = setupBitcoind(t, addr, test.rpcPolling) - testNotifySpentMempool(t, miner1, btcClient) - - // Test looking up mempool for input spent. - testLookupInputMempoolSpend(t, miner1, btcClient) + test.testFn(t, miner1, btcClient) }) } } @@ -91,31 +128,21 @@ func testNotifyTx(t *testing.T, miner *rpctest.Harness, client *BitcoindClient) err = client.NotifyTx([]chainhash.Hash{hash}) require.NoError(err) - _, err = client.SendRawTransaction(tx, true) - require.NoError(err) + // Send the transaction. This might fail if the bitcoind node hasn't + // synced the inputs yet, so we'll retry until it succeeds. + require.Eventually(func() bool { + _, err = client.SendRawTransaction(tx, true) + return err == nil + }, defaultTestTimeout, 100*time.Millisecond, + "SendRawTransaction failed") ntfns := client.Notifications() // We expect to get a ClientConnected notification. - select { - case ntfn := <-ntfns: - _, ok := ntfn.(ClientConnected) - require.Truef(ok, "Expected type ClientConnected, got %T", ntfn) - - case <-time.After(time.Second): - require.Fail("timed out for ClientConnected notification") - } + waitForClientConnected(t, ntfns) // We expect to get a RelevantTx notification. - select { - case ntfn := <-ntfns: - tx, ok := ntfn.(RelevantTx) - require.Truef(ok, "Expected type RelevantTx, got %T", ntfn) - require.True(tx.TxRecord.Hash.IsEqual(&hash)) - - case <-time.After(time.Second): - require.Fail("timed out waiting for RelevantTx notification") - } + waitForRelevantTx(t, ntfns, &hash) } // testNotifyBlocks tests that the correct notifications are received for @@ -134,14 +161,7 @@ func testNotifyBlocks(t *testing.T, miner *rpctest.Harness, miner.Client.Generate(1) // We expect to get a ClientConnected notification. - select { - case ntfn := <-ntfns: - _, ok := ntfn.(ClientConnected) - require.Truef(ok, "Expected type ClientConnected, got %T", ntfn) - - case <-time.After(time.Second): - require.Fail("timed out for ClientConnected notification") - } + waitForClientConnected(t, ntfns) // We expect to get a FilteredBlockConnected notification. select { @@ -150,7 +170,7 @@ func testNotifyBlocks(t *testing.T, miner *rpctest.Harness, require.Truef(ok, "Expected type FilteredBlockConnected, "+ "got %T", ntfn) - case <-time.After(time.Second): + case <-time.After(defaultTestTimeout): require.Fail("timed out for FilteredBlockConnected " + "notification") } @@ -161,7 +181,7 @@ func testNotifyBlocks(t *testing.T, miner *rpctest.Harness, _, ok := ntfn.(BlockConnected) require.Truef(ok, "Expected type BlockConnected, got %T", ntfn) - case <-time.After(time.Second): + case <-time.After(defaultTestTimeout): require.Fail("timed out for BlockConnected notification") } } @@ -195,25 +215,10 @@ func testNotifySpentMempool(t *testing.T, miner *rpctest.Harness, ntfns := client.Notifications() // We expect to get a ClientConnected notification. - select { - case ntfn := <-ntfns: - _, ok := ntfn.(ClientConnected) - require.Truef(ok, "Expected type ClientConnected, got %T", ntfn) - - case <-time.After(time.Second): - require.Fail("timed out for ClientConnected notification") - } + waitForClientConnected(t, ntfns) // We expect to get a RelevantTx notification. - select { - case ntfn := <-ntfns: - tx, ok := ntfn.(RelevantTx) - require.Truef(ok, "Expected type RelevantTx, got %T", ntfn) - require.True(tx.TxRecord.Hash.IsEqual(&txid)) - - case <-time.After(time.Second): - require.Fail("timed out waiting for RelevantTx notification") - } + waitForRelevantTx(t, ntfns, &txid) } // testLookupInputMempoolSpend tests that LookupInputMempoolSpend returns the @@ -250,7 +255,7 @@ func testLookupInputMempoolSpend(t *testing.T, miner *rpctest.Harness, rt.Eventually(func() bool { txid, found = client.LookupInputMempoolSpend(op) return found - }, 5*time.Second, 100*time.Millisecond) + }, defaultTestTimeout, 100*time.Millisecond) // Check the expected txid is returned. rt.Equal(tx.TxHash(), txid) @@ -258,8 +263,10 @@ func testLookupInputMempoolSpend(t *testing.T, miner *rpctest.Harness, // testReorg tests that the given BitcoindClient correctly responds to a chain // re-org. -func testReorg(t *testing.T, miner1, miner2 *rpctest.Harness, - client *BitcoindClient) { +func testReorg(t *testing.T, miner1 *rpctest.Harness, client *BitcoindClient) { + t.Helper() + + miner2 := setupReorgMiner(t, miner1) require := require.New(t) @@ -281,7 +288,7 @@ func testReorg(t *testing.T, miner1, miner2 *rpctest.Harness, _, ok := ntfn.(ClientConnected) require.Truef(ok, "Expected type ClientConnected, got %T", ntfn) - case <-time.After(time.Second): + case <-time.After(defaultTestTimeout): require.Fail("timed out for ClientConnected notification") } @@ -370,7 +377,7 @@ func testReorg(t *testing.T, miner1, miner2 *rpctest.Harness, func waitForBlockNtfn(t *testing.T, ntfns <-chan interface{}, expectedHeight int32, connected bool) chainhash.Hash { - timer := time.NewTimer(2 * time.Second) + timer := time.NewTimer(defaultTestTimeout) for { select { case nftn := <-ntfns: @@ -414,21 +421,47 @@ func waitForBlockNtfn(t *testing.T, ntfns <-chan interface{}, } } -// setUpMiners sets up two miners that can be used for a re-org test. -func setupMiners(t *testing.T) (*rpctest.Harness, *rpctest.Harness) { - trickle := fmt.Sprintf("--trickleinterval=%v", 10*time.Millisecond) - args := []string{trickle} +// setUpMiner sets up a single miner. +func setupMiner(t *testing.T) *rpctest.Harness { + t.Helper() + + args := []string{ + fmt.Sprintf("--trickleinterval=%v", 10*time.Millisecond), + // TODO(yy): We should uncomment the following to allow setting + // up ports here in the test. However, this cannot work without + // modifying the rpcclient in the `btcd` first, as the ports + // are overwritten there. + // + // fmt.Sprintf("--listen=%v", port.NextAvailablePort()), + // fmt.Sprintf("--rpclisten=%v", port.NextAvailablePort()), + } - miner1, err := rpctest.New( - &chaincfg.RegressionNetParams, nil, args, "", - ) + miner, err := rpctest.New(&chaincfg.RegressionNetParams, nil, args, "") require.NoError(t, err) t.Cleanup(func() { - miner1.TearDown() + require.NoError(t, miner.TearDown()) }) - require.NoError(t, miner1.SetUp(true, 1)) + require.NoError(t, miner.SetUp(true, 101)) + + return miner +} + +// setupReorgMiner sets up a second miner that can be used for a re-org test. +func setupReorgMiner(t *testing.T, miner1 *rpctest.Harness) *rpctest.Harness { + t.Helper() + + args := []string{ + fmt.Sprintf("--trickleinterval=%v", 10*time.Millisecond), + // TODO(yy): We should uncomment the following to allow setting + // up ports here in the test. However, this cannot work without + // modifying the rpcclient in the `btcd` first, as the ports + // are overwritten there. + // + // fmt.Sprintf("--listen=%v", port.NextAvailablePort()), + // fmt.Sprintf("--rpclisten=%v", port.NextAvailablePort()), + } miner2, err := rpctest.New( &chaincfg.RegressionNetParams, nil, args, "", @@ -449,7 +482,7 @@ func setupMiners(t *testing.T) (*rpctest.Harness, *rpctest.Harness) { ) require.NoError(t, err) - return miner1, miner2 + return miner2 } // setupBitcoind starts up a bitcoind node with either a zmq connection or @@ -460,10 +493,14 @@ func setupBitcoind(t *testing.T, minerAddr string, // Start a bitcoind instance and connect it to miner1. tempBitcoindDir := t.TempDir() - zmqBlockHost := "ipc:///" + tempBitcoindDir + "/blocks.socket" - zmqTxHost := "ipc:///" + tempBitcoindDir + "/tx.socket" + zmqBlockPort := port.NextAvailablePort() + zmqTxPort := port.NextAvailablePort() + + zmqBlockHost := fmt.Sprintf("tcp://127.0.0.1:%d", zmqBlockPort) + zmqTxHost := fmt.Sprintf("tcp://127.0.0.1:%d", zmqTxPort) - rpcPort := rand.Int()%(65536-1024) + 1024 + rpcPort := port.NextAvailablePort() + p2pPort := port.NextAvailablePort() bitcoind := exec.Command( "bitcoind", "-datadir="+tempBitcoindDir, @@ -474,9 +511,11 @@ func setupBitcoind(t *testing.T, minerAddr string, "d$507c670e800a95284294edb5773b05544b"+ "220110063096c221be9933c82d38e1", fmt.Sprintf("-rpcport=%d", rpcPort), + fmt.Sprintf("-port=%d", p2pPort), "-disablewallet", "-zmqpubrawblock="+zmqBlockHost, "-zmqpubrawtx="+zmqTxHost, + "-blockfilterindex=1", ) require.NoError(t, bitcoind.Start()) @@ -523,13 +562,20 @@ func setupBitcoind(t *testing.T, minerAddr string, }) // Create a bitcoind client. - btcClient := chainConn.NewBitcoindClient() + btcClient, err := chainConn.NewBitcoindClient() + require.NoError(t, err) require.NoError(t, btcClient.Start(t.Context())) t.Cleanup(func() { btcClient.Stop() }) + // Wait for bitcoind to sync with the miner. + require.Eventually(t, func() bool { + _, height, err := btcClient.GetBestBlock() + return err == nil && height >= 101 + }, defaultTestTimeout, 100*time.Millisecond) + return btcClient } @@ -556,3 +602,106 @@ func randPubKeyHashScript() ([]byte, *btcec.PrivateKey, error) { return pkScript, privKey, nil } + +// testBitcoindClientGetCFilter verifies the BitcoindClient's GetCFilter +// implementation by interacting with a live bitcoind node. +func testBitcoindClientGetCFilter(t *testing.T, miner *rpctest.Harness, + client *BitcoindClient) { + + t.Helper() + + require := require.New(t) + + // Generate a block to have something to query a filter for. + hashes, err := miner.Client.Generate(1) + require.NoError(err) + + blockHash := hashes[0] + + // Get the CFilter using the BitcoindClient. This might take a few + // attempts as the filter index might not be immediately available. + var gcsFilter *gcs.Filter + require.Eventually(func() bool { + gcsFilter, err = client.GetCFilter( + blockHash, wire.GCSFilterRegular, + ) + + return err == nil + }, defaultTestTimeout, 100*time.Millisecond, + "GetCFilter should succeed") + require.NotNil(gcsFilter, "GCS filter should not be nil") + require.IsType(&gcs.Filter{}, gcsFilter) + + // Verify the filter matches the block data. + block, err := client.GetBlock(blockHash) + require.NoError(err) + + // Use the first transaction's first output script. + script := block.Transactions[0].TxOut[0].PkScript + + // Derive the filter key. + key := builder.DeriveKey(blockHash) + + // Check match. + matched, err := gcsFilter.Match(key, script) + require.NoError(err) + require.True(matched, "Filter should match script from block") + + // Test with an unsupported filter type. + _, err = client.GetCFilter(blockHash, wire.FilterType(99)) + require.ErrorContains(err, "only basic filters are supported", + "Unsupported filter type should return an error") + + // Test GetCFilter for a non-existent block. + dummyHash := &chainhash.Hash{0x01, 0x02, 0x03} + _, err = client.GetCFilter(dummyHash, wire.GCSFilterRegular) + require.ErrorContains(err, "Block not found", + "Non-existent block should return an error") +} + +// waitForClientConnected waits for a ClientConnected notification on the passed +// channel. Any other notifications received while waiting are ignored. +func waitForClientConnected(t *testing.T, ntfns <-chan any) { + t.Helper() + + timer := time.NewTimer(defaultTestTimeout) + defer timer.Stop() + + for { + select { + case ntfn := <-ntfns: + if _, ok := ntfn.(ClientConnected); ok { + return + } + + case <-timer.C: + require.FailNow(t, "timed out for ClientConnected "+ + "notification") + } + } +} + +// waitForRelevantTx waits for a RelevantTx notification for the passed tx +// hash on the passed channel. Any other notifications received while waiting +// are ignored. +func waitForRelevantTx(t *testing.T, ntfns <-chan any, hash *chainhash.Hash) { + t.Helper() + + timer := time.NewTimer(defaultTestTimeout) + defer timer.Stop() + + for { + select { + case ntfn := <-ntfns: + if tx, ok := ntfn.(RelevantTx); ok { + if tx.TxRecord.Hash.IsEqual(hash) { + return + } + } + + case <-timer.C: + require.FailNow(t, "timed out waiting for RelevantTx "+ + "notification") + } + } +} diff --git a/chain/btcd.go b/chain/btcd.go index 35af57a4c0..6d62a52293 100644 --- a/chain/btcd.go +++ b/chain/btcd.go @@ -39,6 +39,8 @@ type RPCClient struct { wg sync.WaitGroup started bool quitMtx sync.Mutex + + batchClient *rpcclient.Client } // A compile-time check to ensure that RPCClient satisfies the chain.Interface @@ -91,7 +93,25 @@ func NewRPCClient(chainParams *chaincfg.Params, connect, user, pass string, cert if err != nil { return nil, err } + + batchConfig := *client.connConfig + + // The batch client is exclusively used for batch RPC calls, which + // require HTTP POST mode. Therefore, we explicitly set HTTPPostMode to + // true and clear the Endpoint field to ensure the batch client is + // correctly configured, regardless of the main client's WebSocket (ws) + // or HTTP POST configuration. + batchConfig.HTTPPostMode = true + batchConfig.Endpoint = "" + + batchClient, err := rpcclient.NewBatch(&batchConfig) + if err != nil { + return nil, fmt.Errorf("failed to create batch client: %w", err) + } + + client.batchClient = batchClient client.Client = rpcClient + return client, nil } @@ -194,7 +214,24 @@ func NewRPCClientWithConfig(cfg *RPCClientConfig) (*RPCClient, error) { return nil, err } + batchConfig := *cfg.Conn + + // The batch client is exclusively used for batch RPC calls, which + // require HTTP POST mode. Therefore, we explicitly set HTTPPostMode to + // true and clear the Endpoint field to ensure the batch client is + // correctly configured, regardless of the main client's WebSocket (ws) + // or HTTP POST configuration. + batchConfig.HTTPPostMode = true + batchConfig.Endpoint = "" + + batchClient, err := rpcclient.NewBatch(&batchConfig) + if err != nil { + return nil, fmt.Errorf("failed to create batch client: %w", err) + } + + client.batchClient = batchClient client.Client = rpcClient + return client, nil } @@ -244,6 +281,8 @@ func (c *RPCClient) Stop() { close(c.quit) c.Client.Shutdown() c.Client.WaitForShutdown() + c.batchClient.Shutdown() + c.batchClient.WaitForShutdown() if !c.started { close(c.dequeueNotification) @@ -283,6 +322,28 @@ func (c *RPCClient) Rescan(startHash *chainhash.Hash, addrs []btcutil.Address, return c.Client.Rescan(startHash, addrs, flatOutpoints) // nolint:staticcheck } +// GetCFilter returns a compact filter for the given block hash and filter +// type. It wraps the underlying rpcclient method and converts the result to a +// *gcs.Filter. +func (c *RPCClient) GetCFilter(hash *chainhash.Hash, + filterType wire.FilterType) (*gcs.Filter, error) { + + rawFilter, err := c.Client.GetCFilter(hash, filterType) + if err != nil { + return nil, fmt.Errorf("failed to get filter: %w", err) + } + + filter, err := gcs.FromNBytes( + builder.DefaultP, builder.DefaultM, rawFilter.Data, + ) + if err != nil { + return nil, fmt.Errorf("failed to create filter from bytes: %w", + err) + } + + return filter, nil +} + // WaitForShutdown blocks until both the client has finished disconnecting // and all handlers have exited. func (c *RPCClient) WaitForShutdown() { @@ -333,7 +394,9 @@ func (c *RPCClient) FilterBlocks( // the filter returns a positive match, the full block is then requested // and scanned for addresses using the block filterer. for i, blk := range req.Blocks { - rawFilter, err := c.GetCFilter(&blk.Hash, wire.GCSFilterRegular) + rawFilter, err := c.Client.GetCFilter( + &blk.Hash, wire.GCSFilterRegular, + ) if err != nil { return nil, err } @@ -640,3 +703,133 @@ func (c *RPCClient) SendRawTransaction(tx *wire.MsgTx, return txid, nil } + +// GetBlockHashes returns a slice of block hashes for the given height range. +func (c *RPCClient) GetBlockHashes(startHeight, + endHeight int64) ([]chainhash.Hash, error) { + + if startHeight > endHeight { + return nil, fmt.Errorf("%w: start height %d, end height %d", + ErrInvalidParam, startHeight, endHeight) + } + + count := endHeight - startHeight + 1 + hashes := make([]chainhash.Hash, 0, count) + futures := make([]rpcclient.FutureGetBlockHashResult, 0, count) + + for h := startHeight; h <= endHeight; h++ { + futures = append(futures, c.batchClient.GetBlockHashAsync(h)) + } + + err := c.batchClient.Send() + if err != nil { + return nil, fmt.Errorf("batch send: %w", err) + } + + for _, f := range futures { + hash, err := f.Receive() + if err != nil { + return nil, fmt.Errorf("receive block hash: %w", err) + } + + hashes = append(hashes, *hash) + } + + return hashes, nil +} + +// GetCFilters returns a slice of filters for the given block hashes. +func (c *RPCClient) GetCFilters(hashes []chainhash.Hash, + filterType wire.FilterType) ([]*gcs.Filter, error) { + + filters := make([]*gcs.Filter, 0, len(hashes)) + futures := make([]rpcclient.FutureGetCFilterResult, 0, len(hashes)) + + for _, hash := range hashes { + futures = append( + futures, + c.batchClient.GetCFilterAsync(&hash, filterType), + ) + } + + err := c.batchClient.Send() + if err != nil { + return nil, fmt.Errorf("batch send: %w", err) + } + + for _, f := range futures { + msgFilter, err := f.Receive() + if err != nil { + return nil, fmt.Errorf("receive cfilter: %w", err) + } + + filter, err := gcs.FromNBytes( + builder.DefaultP, builder.DefaultM, msgFilter.Data, + ) + if err != nil { + return nil, fmt.Errorf("parse cfilter: %w", err) + } + + filters = append(filters, filter) + } + + return filters, nil +} + +// GetBlocks returns a slice of full blocks for the given block hashes. +func (c *RPCClient) GetBlocks(hashes []chainhash.Hash) ( + []*wire.MsgBlock, error) { + + blocks := make([]*wire.MsgBlock, 0, len(hashes)) + futures := make([]rpcclient.FutureGetBlockResult, 0, len(hashes)) + + for _, hash := range hashes { + futures = append(futures, c.batchClient.GetBlockAsync(&hash)) + } + + err := c.batchClient.Send() + if err != nil { + return nil, fmt.Errorf("batch send: %w", err) + } + + for _, f := range futures { + block, err := f.Receive() + if err != nil { + return nil, fmt.Errorf("receive block: %w", err) + } + + blocks = append(blocks, block) + } + + return blocks, nil +} + +// GetBlockHeaders returns a slice of block headers for the given block hashes. +func (c *RPCClient) GetBlockHeaders(hashes []chainhash.Hash) ( + []*wire.BlockHeader, error) { + + headers := make([]*wire.BlockHeader, 0, len(hashes)) + futures := make([]rpcclient.FutureGetBlockHeaderResult, 0, len(hashes)) + + for _, hash := range hashes { + futures = append( + futures, c.batchClient.GetBlockHeaderAsync(&hash), + ) + } + + err := c.batchClient.Send() + if err != nil { + return nil, fmt.Errorf("batch send: %w", err) + } + + for _, f := range futures { + header, err := f.Receive() + if err != nil { + return nil, fmt.Errorf("receive header: %w", err) + } + + headers = append(headers, header) + } + + return headers, nil +} diff --git a/chain/btcd_test.go b/chain/btcd_test.go index 5d0abb575b..63aa611e5a 100644 --- a/chain/btcd_test.go +++ b/chain/btcd_test.go @@ -1,13 +1,64 @@ package chain import ( + "fmt" "testing" + "time" "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/integration/rpctest" "github.com/btcsuite/btcd/rpcclient" + "github.com/btcsuite/btcd/wire" "github.com/stretchr/testify/require" ) +// setupBtcd starts up a btcd node with cfilters enabled and returns a client +// wrapper of this connection. +func setupBtcd(t *testing.T) (*rpctest.Harness, *RPCClient) { + t.Helper() + + trickle := fmt.Sprintf("--trickleinterval=%v", 10*time.Millisecond) + args := []string{trickle} + + miner, err := rpctest.New( + &chaincfg.RegressionNetParams, nil, args, "", + ) + require.NoError(t, err) + + require.NoError(t, miner.SetUp(true, 1)) + + t.Cleanup(func() { + require.NoError(t, miner.TearDown()) + }) + + rpcConf := miner.RPCConfig() + client, err := NewRPCClientWithConfig(&RPCClientConfig{ + ReconnectAttempts: 1, + Chain: &chaincfg.RegressionNetParams, + Conn: &rpcclient.ConnConfig{ + Host: rpcConf.Host, + User: rpcConf.User, + Pass: rpcConf.Pass, + Certificates: rpcConf.Certificates, + DisableTLS: false, + DisableAutoReconnect: false, + DisableConnectOnNew: true, + HTTPPostMode: false, + Endpoint: "ws", + }, + }) + require.NoError(t, err) + + err = client.Start(t.Context()) + require.NoError(t, err) + + t.Cleanup(func() { + client.Stop() + }) + + return miner, client +} + // TestValidateConfig checks the `validate` method on the RPCClientConfig // behaves as expected. func TestValidateConfig(t *testing.T) { @@ -56,3 +107,90 @@ func TestValidateConfig(t *testing.T) { _, err := NewRPCClientWithConfig(nil) rt.ErrorContains(err, "missing rpc config") } + +// testInterfaceBatchMethods verifies the batch fetching methods implementation +// for a given chain.Interface client. +func testInterfaceBatchMethods(t *testing.T, miner *rpctest.Harness, + client Interface) { + + t.Helper() + + require := require.New(t) + + // Generate blocks to have a chain to query. + const numBlocks = 5 + + _, err := miner.Client.Generate(numBlocks) + require.NoError(err) + + // Test GetBlockHashes. + // Query from height 1 to 3. + startHeight := int64(1) + endHeight := int64(3) + hashes, err := client.GetBlockHashes(startHeight, endHeight) + require.NoError(err, "GetBlockHashes failed") + require.Len(hashes, 3) + + // Verify hashes match miner. + for i, hash := range hashes { + minerHash, err := miner.Client.GetBlockHash(int64(i) + 1) + require.NoError(err) + require.Equal(*minerHash, hash) + } + + // Test GetBlocks. + blocks, err := client.GetBlocks(hashes) + require.NoError(err, "GetBlocks failed") + require.Len(blocks, 3) + + for i, block := range blocks { + require.Equal(hashes[i], block.BlockHash()) + } + + // Test GetBlockHeaders. + headers, err := client.GetBlockHeaders(hashes) + require.NoError(err, "GetBlockHeaders failed") + require.Len(headers, 3) + + for i, header := range headers { + require.Equal(hashes[i], header.BlockHash()) + } + + // Test GetCFilters. + // Note: bitcoind needs -blockfilterindex=1 for this to work, which is + // set in setupBitcoind. + // We use Eventually because filter indexing is asynchronous. + require.Eventually(func() bool { + filters, err := client.GetCFilters( + hashes, wire.GCSFilterRegular, + ) + if err != nil { + return false + } + + if len(filters) != 3 { + return false + } + // Verify filters are not empty/nil. + for _, f := range filters { + if f == nil || f.N() == 0 { + return false + } + } + + return true + }, defaultTestTimeout, 100*time.Millisecond, + "GetCFilters failed or timed out") +} + +// TestRPCClientBatchMethods verifies the RPCClient's batch fetching methods +// implementation against a live btcd node. +func TestRPCClientBatchMethods(t *testing.T) { + t.Parallel() + + // Set up a miner (btcd node) and client. + miner, client := setupBtcd(t) + + // Run batch method tests. + testInterfaceBatchMethods(t, miner, client) +} diff --git a/chain/interface.go b/chain/interface.go index bb3a842e97..81f2e81eae 100644 --- a/chain/interface.go +++ b/chain/interface.go @@ -6,6 +6,7 @@ import ( "github.com/btcsuite/btcd/btcjson" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/gcs" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/rpcclient" "github.com/btcsuite/btcd/wire" @@ -41,6 +42,8 @@ type Interface interface { GetBlockHash(int64) (*chainhash.Hash, error) GetBlockHeader(*chainhash.Hash) (*wire.BlockHeader, error) IsCurrent() bool + GetCFilter(hash *chainhash.Hash, + filterType wire.FilterType) (*gcs.Filter, error) FilterBlocks(*FilterBlocksRequest) (*FilterBlocksResponse, error) BlockStamp() (*waddrmgr.BlockStamp, error) SendRawTransaction(*wire.MsgTx, bool) (*chainhash.Hash, error) @@ -51,6 +54,32 @@ type Interface interface { BackEnd() string TestMempoolAccept([]*wire.MsgTx, float64) ([]*btcjson.TestMempoolAcceptResult, error) MapRPCErr(err error) error + + // Batching methods for optimized scanning. + // + // GetBlockHashes returns a slice of block hashes for the given height + // range (inclusive). + // + // NOTE: This is a batching method, designed for optimized scanning. + GetBlockHashes(startHeight, endHeight int64) ([]chainhash.Hash, error) + + // GetCFilters returns a slice of compact filters for the given block + // hashes and filter type. + // + // NOTE: This is a batching method, designed for optimized scanning. + GetCFilters(hashes []chainhash.Hash, + filterType wire.FilterType) ([]*gcs.Filter, error) + + // GetBlocks returns a slice of full blocks for the given block hashes. + // + // NOTE: This is a batching method, designed for optimized scanning. + GetBlocks(hashes []chainhash.Hash) ([]*wire.MsgBlock, error) + + // GetBlockHeaders returns a slice of block headers for the given block + // hashes. + // + // NOTE: This is a batching method, designed for optimized scanning. + GetBlockHeaders(hashes []chainhash.Hash) ([]*wire.BlockHeader, error) } // Notification types. These are defined here and processed from from reading diff --git a/chain/jitter_test.go b/chain/jitter_test.go index 9d62eab13f..16d1d5804b 100644 --- a/chain/jitter_test.go +++ b/chain/jitter_test.go @@ -91,8 +91,8 @@ func TestJitterTicker(t *testing.T) { // Tick duration should be between 80ms and 120ms. require.True(t, diff >= 80*time.Millisecond, "diff: %v", diff) - // We give 1ms more to account for the time it takes to run the + // We give 5ms more to account for the time it takes to run the // code. - require.True(t, diff < 121*time.Millisecond, "diff: %v", diff) + require.Less(t, diff, 125*time.Millisecond, "diff: %v", diff) } } diff --git a/chain/mocks_test.go b/chain/mocks_test.go index 81c44b20c9..3e99e4972e 100644 --- a/chain/mocks_test.go +++ b/chain/mocks_test.go @@ -67,96 +67,115 @@ func (m *mockRescanner) WaitForShutdown() { // mockChainService is a mock implementation of a chain service for use in // tests. Only the Start, GetBlockHeader and BestBlock methods are implemented. type mockChainService struct { + mock.Mock } func (m *mockChainService) Start(_ context.Context) error { - return nil + args := m.Called() + return args.Error(0) } func (m *mockChainService) BestBlock() (*headerfs.BlockStamp, error) { - return testBestBlock, nil + args := m.Called() + return args.Get(0).(*headerfs.BlockStamp), args.Error(1) } func (m *mockChainService) GetBlockHeader( - *chainhash.Hash) (*wire.BlockHeader, error) { + hash *chainhash.Hash) (*wire.BlockHeader, error) { - return &wire.BlockHeader{}, nil + args := m.Called(hash) + return args.Get(0).(*wire.BlockHeader), args.Error(1) } -func (m *mockChainService) GetBlock(chainhash.Hash, - ...neutrino.QueryOption) (*btcutil.Block, error) { +func (m *mockChainService) GetBlock( + hash chainhash.Hash, + options ...neutrino.QueryOption) (*btcutil.Block, error) { - return nil, errNotImplemented + args := m.Called(hash, options) + return args.Get(0).(*btcutil.Block), args.Error(1) } -func (m *mockChainService) GetBlockHeight(*chainhash.Hash) (int32, error) { - return 0, errNotImplemented +func (m *mockChainService) GetBlockHeight(hash *chainhash.Hash) (int32, error) { + args := m.Called(hash) + return args.Get(0).(int32), args.Error(1) } -func (m *mockChainService) GetBlockHash(int64) (*chainhash.Hash, error) { - return nil, errNotImplemented +func (m *mockChainService) GetBlockHash(height int64) (*chainhash.Hash, error) { + args := m.Called(height) + return args.Get(0).(*chainhash.Hash), args.Error(1) } func (m *mockChainService) IsCurrent() bool { - return false + args := m.Called() + return args.Bool(0) } -func (m *mockChainService) SendTransaction(*wire.MsgTx) error { - return errNotImplemented +func (m *mockChainService) SendTransaction(tx *wire.MsgTx) error { + args := m.Called(tx) + return args.Error(0) } -func (m *mockChainService) GetCFilter(chainhash.Hash, - wire.FilterType, ...neutrino.QueryOption) (*gcs.Filter, error) { +func (m *mockChainService) GetCFilter( + hash chainhash.Hash, filterType wire.FilterType, + options ...neutrino.QueryOption) (*gcs.Filter, error) { - return nil, errNotImplemented + args := m.Called(hash, filterType, options) + return args.Get(0).(*gcs.Filter), args.Error(1) } func (m *mockChainService) GetUtxo( - _ ...neutrino.RescanOption) (*neutrino.SpendReport, error) { + opts ...neutrino.RescanOption) (*neutrino.SpendReport, error) { - return nil, errNotImplemented + args := m.Called(opts) + return args.Get(0).(*neutrino.SpendReport), args.Error(1) } -func (m *mockChainService) BanPeer(string, banman.Reason) error { - return errNotImplemented +func (m *mockChainService) BanPeer(addr string, reason banman.Reason) error { + args := m.Called(addr, reason) + return args.Error(0) } func (m *mockChainService) IsBanned(addr string) bool { - panic(errNotImplemented) + args := m.Called(addr) + return args.Bool(0) } -func (m *mockChainService) AddPeer(*neutrino.ServerPeer) { - panic(errNotImplemented) +func (m *mockChainService) AddPeer(peer *neutrino.ServerPeer) { + m.Called(peer) } -func (m *mockChainService) AddBytesSent(uint64) { - panic(errNotImplemented) +func (m *mockChainService) AddBytesSent(bytes uint64) { + m.Called(bytes) } -func (m *mockChainService) AddBytesReceived(uint64) { - panic(errNotImplemented) +func (m *mockChainService) AddBytesReceived(bytes uint64) { + m.Called(bytes) } func (m *mockChainService) NetTotals() (uint64, uint64) { - panic(errNotImplemented) + args := m.Called() + return args.Get(0).(uint64), args.Get(1).(uint64) } -func (m *mockChainService) UpdatePeerHeights(*chainhash.Hash, - int32, *neutrino.ServerPeer, -) { - panic(errNotImplemented) +func (m *mockChainService) UpdatePeerHeights(hash *chainhash.Hash, + height int32, peer *neutrino.ServerPeer) { + + m.Called(hash, height, peer) } func (m *mockChainService) ChainParams() chaincfg.Params { - panic(errNotImplemented) + args := m.Called() + return args.Get(0).(chaincfg.Params) } func (m *mockChainService) Stop() error { - panic(errNotImplemented) + args := m.Called() + return args.Error(0) } -func (m *mockChainService) PeerByAddr(string) *neutrino.ServerPeer { - panic(errNotImplemented) +func (m *mockChainService) PeerByAddr(addr string) *neutrino.ServerPeer { + args := m.Called(addr) + return args.Get(0).(*neutrino.ServerPeer) } // mockRPCClient mocks the rpcClient interface. diff --git a/chain/neutrino.go b/chain/neutrino.go index ea899f40e7..2368da70f7 100644 --- a/chain/neutrino.go +++ b/chain/neutrino.go @@ -217,6 +217,22 @@ func (s *NeutrinoClient) IsCurrent() bool { return s.CS.IsCurrent() } +// GetCFilter returns a compact filter for the given block hash and filter +// type. +// +// NOTE: This is part of the chain.Interface interface. +func (s *NeutrinoClient) GetCFilter(blockHash *chainhash.Hash, + filterType wire.FilterType) (*gcs.Filter, error) { + + filter, err := s.CS.GetCFilter(*blockHash, filterType) + if err != nil { + return nil, fmt.Errorf("failed to get filter from "+ + "neutrino: %w", err) + } + + return filter, nil +} + // SendRawTransaction replicates the RPC client's SendRawTransaction command. func (s *NeutrinoClient) SendRawTransaction(tx *wire.MsgTx, allowHighFees bool) ( *chainhash.Hash, error) { @@ -565,6 +581,85 @@ func (s *NeutrinoClient) SetStartTime(startTime time.Time) { s.startTime = startTime } +// GetBlockHashes returns a slice of block hashes for the given height range. +func (s *NeutrinoClient) GetBlockHashes(startHeight, endHeight int64) ( + []chainhash.Hash, error) { + + if startHeight > endHeight { + return nil, fmt.Errorf("%w: start height %d, end height %d", + ErrInvalidParam, startHeight, endHeight) + } + + count := endHeight - startHeight + 1 + + hashes := make([]chainhash.Hash, 0, count) + + for h := startHeight; h <= endHeight; h++ { + hash, err := s.CS.GetBlockHash(h) + if err != nil { + return nil, fmt.Errorf("get block hash: %w", err) + } + + hashes = append(hashes, *hash) + } + + return hashes, nil +} + +// GetCFilters returns a slice of filters for the given block hashes. +func (s *NeutrinoClient) GetCFilters(hashes []chainhash.Hash, + filterType wire.FilterType) ([]*gcs.Filter, error) { + + filters := make([]*gcs.Filter, 0, len(hashes)) + + for _, hash := range hashes { + filter, err := s.CS.GetCFilter(hash, filterType) + if err != nil { + return nil, fmt.Errorf("get cfilter: %w", err) + } + + filters = append(filters, filter) + } + + return filters, nil +} + +// GetBlocks returns a slice of full blocks for the given block hashes. +func (s *NeutrinoClient) GetBlocks(hashes []chainhash.Hash) ( + []*wire.MsgBlock, error) { + + blocks := make([]*wire.MsgBlock, 0, len(hashes)) + + for _, hash := range hashes { + block, err := s.CS.GetBlock(hash) + if err != nil { + return nil, fmt.Errorf("get block: %w", err) + } + + blocks = append(blocks, block.MsgBlock()) + } + + return blocks, nil +} + +// GetBlockHeaders returns a slice of block headers for the given block hashes. +func (s *NeutrinoClient) GetBlockHeaders(hashes []chainhash.Hash) ( + []*wire.BlockHeader, error) { + + headers := make([]*wire.BlockHeader, 0, len(hashes)) + + for _, hash := range hashes { + header, err := s.CS.GetBlockHeader(&hash) + if err != nil { + return nil, fmt.Errorf("get block header: %w", err) + } + + headers = append(headers, header) + } + + return headers, nil +} + // onFilteredBlockConnected sends appropriate notifications to the notification // channel. func (s *NeutrinoClient) onFilteredBlockConnected(height int32, diff --git a/chain/neutrino_test.go b/chain/neutrino_test.go index 88d55f0357..fec5be6330 100644 --- a/chain/neutrino_test.go +++ b/chain/neutrino_test.go @@ -7,14 +7,85 @@ import ( "time" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/gcs" + "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/neutrino/headerfs" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) // maxDur is the max duration a test has to execute successfully. var maxDur = 5 * time.Second +// TestNeutrinoClientBatchFetch verifies that the batch fetching methods +// correctly loop over the range/list and call the underlying service. +func TestNeutrinoClientBatchFetch(t *testing.T) { + t.Parallel() + + nc := newMockNeutrinoClient() + mockCS, ok := nc.CS.(*mockChainService) + require.True(t, ok) + + // Clear default expectations set in newMockNeutrinoClient so we can + // set strict expectations for this test. + + // Test GetBlockHashes + startHeight := int64(100) + endHeight := int64(102) + hash1 := chainhash.Hash{1} + hash2 := chainhash.Hash{2} + hash3 := chainhash.Hash{3} + + mockCS.On("GetBlockHash", int64(100)).Return(&hash1, nil).Once() + mockCS.On("GetBlockHash", int64(101)).Return(&hash2, nil).Once() + mockCS.On("GetBlockHash", int64(102)).Return(&hash3, nil).Once() + + hashes, err := nc.GetBlockHashes(startHeight, endHeight) + require.NoError(t, err) + require.Len(t, hashes, 3) + require.Equal(t, hash1, hashes[0]) + require.Equal(t, hash2, hashes[1]) + require.Equal(t, hash3, hashes[2]) + + // Test GetCFilters + filterType := wire.GCSFilterRegular + filter1 := &gcs.Filter{} // Empty filter + mockCS.On("GetCFilter", hash1, filterType, mock.Anything). + Return(filter1, nil).Once() + mockCS.On("GetCFilter", hash2, filterType, mock.Anything). + Return(filter1, nil).Once() + mockCS.On("GetCFilter", hash3, filterType, mock.Anything). + Return(filter1, nil).Once() + + filters, err := nc.GetCFilters(hashes, filterType) + require.NoError(t, err) + require.Len(t, filters, 3) + + // Test GetBlocks + block1 := btcutil.NewBlock(&wire.MsgBlock{}) + mockCS.On("GetBlock", hash1, mock.Anything).Return(block1, nil).Once() + mockCS.On("GetBlock", hash2, mock.Anything).Return(block1, nil).Once() + mockCS.On("GetBlock", hash3, mock.Anything).Return(block1, nil).Once() + + blocks, err := nc.GetBlocks(hashes) + require.NoError(t, err) + require.Len(t, blocks, 3) + + // Test GetBlockHeaders + header1 := &wire.BlockHeader{} + mockCS.On("GetBlockHeader", &hash1).Return(header1, nil).Once() + mockCS.On("GetBlockHeader", &hash2).Return(header1, nil).Once() + mockCS.On("GetBlockHeader", &hash3).Return(header1, nil).Once() + + headers, err := nc.GetBlockHeaders(hashes) + require.NoError(t, err) + require.Len(t, headers, 3) + + mockCS.AssertExpectations(t) +} + // TestNeutrinoClientSequentialStartStop ensures that the client // can sequentially Start and Stop without errors or races. func TestNeutrinoClientSequentialStartStop(t *testing.T) { @@ -23,6 +94,18 @@ func TestNeutrinoClientSequentialStartStop(t *testing.T) { wantRestarts = 50 ) + mockCS, ok := nc.CS.(*mockChainService) + require.True(t, ok) + + testBestBlock := &headerfs.BlockStamp{ + Hash: chainhash.Hash(make([]byte, 32)), + Height: 1, + } + + mockCS.On("Start").Return(nil).Times(wantRestarts) + mockCS.On("Stop").Return(nil).Times(wantRestarts) + mockCS.On("BestBlock").Return(testBestBlock, nil).Maybe() + // callStartStop starts the neutrino client, requires no error on // startup, immediately stops the client and waits for shutdown. // The returned channel is closed once shutdown is complete. @@ -118,13 +201,29 @@ func TestNeutrinoClientNotifyReceivedRescan(t *testing.T) { gotMsgs = 0 msgCh = make(chan string, wantMsgs) msgPrefix = "successfully called" - - // sendMsg writes a message to the buffered message channel. - sendMsg = func(s string) { - msgCh <- fmt.Sprintf("%s %s", msgPrefix, s) - } ) + mockCS, ok := nc.CS.(*mockChainService) + require.True(t, ok) + + testBestBlock := &headerfs.BlockStamp{ + Hash: chainhash.Hash(make([]byte, 32)), + Height: 1, + } + + testBlockHeader := &wire.BlockHeader{Timestamp: time.Unix(1, 0)} + + mockCS.On("Start").Return(nil).Once() + mockCS.On("Stop").Return(nil).Once() + mockCS.On("BestBlock").Return(testBestBlock, nil).Maybe() + mockCS.On("GetBlockHeader", mock.Anything). + Return(testBlockHeader, nil).Maybe() + + // sendMsg writes a message to the buffered message channel. + sendMsg := func(s string) { + msgCh <- fmt.Sprintf("%s %s", msgPrefix, s) + } + // Define closures to wrap desired neutrino client method calls. // cleanup is the shared cleanup function for a closure executing diff --git a/chain/port/port.go b/chain/port/port.go new file mode 100644 index 0000000000..59eab24adf --- /dev/null +++ b/chain/port/port.go @@ -0,0 +1,198 @@ +// Package port provides functionality for managing network ports, including +// finding available ports and ensuring exclusive access using lock files. +package port + +import ( + "context" + "fmt" + "net" + "os" + "path/filepath" + "strconv" + "sync" + "time" +) + +const ( + // defaultTimeout is the default timeout that is used for the wait + // package. + defaultTimeout = 30 * time.Second + + // ListenerFormat is the format string that is used to generate local + // listener addresses. + ListenerFormat = "127.0.0.1:%d" + + // defaultNodePort is the start of the range for listening ports of + // harness nodes. Ports are monotonically increasing starting from this + // number and are determined by the results of NextAvailablePort(). + defaultNodePort int = 10000 + + // uniquePortFile is the name of the file that is used to store the + // last port that was used by a node. This is used to make sure that + // the same port is not used by multiple nodes at the same time. The + // file is located in the temp directory of a system. + uniquePortFile = "rpctest-port" + + // filePerms is the file permission used for the lock file and port + // file. + filePerms = 0600 + + // retryInterval is the interval to wait before retrying to acquire the + // lock file. + retryInterval = 10 * time.Millisecond + + // maxPort is the maximum valid port number. + maxPort = 65535 +) + +var ( + // portFileMutex is a mutex that is used to make sure that the port file + // is not accessed by multiple goroutines of the same process at the + // same time. This is used in conjunction with the lock file to make + // sure that the port file is not accessed by multiple processes at the + // same time either. So the lock file is to guard between processes and + // the mutex is to guard between goroutines of the same process. + portFileMutex sync.Mutex +) + +// NextAvailablePort returns the first port that is available for listening by a +// new node, using a lock file to make sure concurrent access for parallel tasks +// on the same system don't re-use the same port. +func NextAvailablePort() int { + portFileMutex.Lock() + defer portFileMutex.Unlock() + + lockFile := filepath.Join(os.TempDir(), uniquePortFile+".lock") + lockFile = filepath.Clean(lockFile) + lockFileHandle := acquireLockFile(lockFile) + + // Release the lock file when we're done. + defer func() { + // Always close file first, Windows won't allow us to remove it + // otherwise. + _ = lockFileHandle.Close() + + err := os.Remove(lockFile) + if err != nil { + panic(fmt.Errorf("couldn't remove lock file: %w", err)) + } + }() + + portFile := filepath.Join(os.TempDir(), uniquePortFile) + portFile = filepath.Clean(portFile) + + port, err := os.ReadFile(portFile) + if err != nil { + if !os.IsNotExist(err) { + panic(fmt.Errorf("error reading port file: %w", err)) + } + + port = []byte(strconv.Itoa(defaultNodePort)) + } + + lastPort, err := strconv.Atoi(string(port)) + if err != nil { + panic(fmt.Errorf("error parsing port: %w", err)) + } + + // lastPort has reached the max allowed port, we start with the default + // node port. + if lastPort >= maxPort { + lastPort = defaultNodePort + } + + // Determine the first port to try. + nextPort := lastPort + 1 + + availablePort := findAvailablePort(nextPort) + + err = os.WriteFile( + portFile, []byte(strconv.Itoa(availablePort)), filePerms, + ) + if err != nil { + panic(fmt.Errorf("error updating port file: %w", err)) + } + + return availablePort +} + +// findAvailablePort searches for an available port starting from the given +// port. If it reaches the maximum port number, it wraps around to the default +// node port and continues searching until it has checked the entire range. +func findAvailablePort(startPort int) int { + currentPort := startPort + for { + // If there are no errors while attempting to listen on this + // port, close the socket and return it as available. While it + // could be the case that some other process picks up this port + // between the time the socket is closed, and it's reopened in + // the harness node, in practice in CI servers this seems much + // less likely than simply some other process already being + // bound at the start of the tests. + addr := fmt.Sprintf(ListenerFormat, currentPort) + + lc := &net.ListenConfig{} + + l, err := lc.Listen(context.Background(), "tcp4", addr) + if err == nil { + _ = l.Close() + return currentPort + } + + currentPort++ + + // Start from the beginning if we reached the end of the port + // range. We need to do this because the lock file now is + // persistent across runs on the same machine during the same + // boot/uptime cycle. So in order to make this work on + // developer's machines, we need to reset the port to the + // default value when we reach the end of the range. + if currentPort > maxPort { + currentPort = defaultNodePort + } + + // If we reached the start port again, it means no ports are + // available. + if currentPort == startPort { + break + } + } + + // No ports available? Must be a mistake. + panic("no ports available for listening") +} + +// acquireLockFile attempts to acquire the lock file. If it already exists, it +// waits for a bit and retries until the timeout is reached. If the process is +// killed before the lock file is removed, this function will timeout and panic. +// In that case, the lock file must be manually removed. +func acquireLockFile(lockFile string) *os.File { + timeout := time.After(defaultTimeout) + + var ( + lockFileHandle *os.File + err error + ) + for { + // Attempt to acquire the lock file. If it already exists, wait + // for a bit and retry. + // + //nolint:gosec // lockFile is constructed from os.TempDir() and + // a constant, not from user input. + lockFileHandle, err = os.OpenFile( + lockFile, os.O_CREATE|os.O_EXCL, filePerms, + ) + if err == nil { + // Lock acquired. + return lockFileHandle + } + + // Wait for a bit and retry. + select { + case <-timeout: + str := "timeout waiting for lock file: " + lockFile + panic(str) + case <-time.After(retryInterval): + } + } +} diff --git a/chain/pruned_block_dispatcher_test.go b/chain/pruned_block_dispatcher_test.go index 990d0d3a63..cea038177a 100644 --- a/chain/pruned_block_dispatcher_test.go +++ b/chain/pruned_block_dispatcher_test.go @@ -67,7 +67,7 @@ func newNetworkBlockTestHarness(t *testing.T, numBlocks, localConns: make(map[string]net.Conn, numPeers), remoteConns: make(map[string]net.Conn, numPeers), dialedPeer: make(chan string), - queriedPeer: make(chan struct{}), + queriedPeer: make(chan struct{}, numBlocks*numPeers), blocksQueried: make(map[chainhash.Hash]int), shouldReply: 0, } @@ -181,10 +181,18 @@ func (h *prunedBlockDispatcherHarness) stop() { default: } - select { - case <-h.queriedPeer: - h.t.Fatal("did not consume all queriedPeer signals") - default: + // Drain any remaining queriedPeer signals. The exact number of peer + // queries depends on the work manager's internal scheduling (e.g. + // retries, worker redistribution) and is non-deterministic. Tests + // that care about specific query counts already assert them + // explicitly via assertPeerQueried. +drainQueriedPeer: + for { + select { + case <-h.queriedPeer: + default: + break drainQueriedPeer + } } require.Empty(h.t, h.blocksQueried) @@ -271,11 +279,6 @@ func (h *prunedBlockDispatcherHarness) query(blocks []*chainhash.Hash, cancelChan := make(chan error, 1) blockChan, errChan := h.dispatcher.Query(blocks, cancelChan, opts...) - select { - case err := <-errChan: - require.NoError(h.t, err) - default: - } for _, block := range blocks { h.blocksQueried[*block]++ @@ -327,7 +330,7 @@ func (h *prunedBlockDispatcherHarness) disconnectPeer(addr string, fallback bool h.dispatcher.peerMtx.Lock() defer h.dispatcher.peerMtx.Unlock() return len(h.dispatcher.currentPeers) == numPeers-1 - }, time.Second, 200*time.Millisecond) + }, defaultTestTimeout, 200*time.Millisecond) // Reset the peer connection state to allow connections to them again. h.resetPeer(addr, fallback) @@ -339,7 +342,7 @@ func (h *prunedBlockDispatcherHarness) assertPeerDialed() { select { case <-h.dialedPeer: - case <-time.After(5 * time.Second): + case <-time.After(defaultTestTimeout): h.t.Fatalf("expected peer to be dialed") } } @@ -351,7 +354,7 @@ func (h *prunedBlockDispatcherHarness) assertPeerDialedWithAddr(addr string) { select { case dialedAddr := <-h.dialedPeer: require.Equal(h.t, addr, dialedAddr) - case <-time.After(5 * time.Second): + case <-time.After(defaultTestTimeout): h.t.Fatalf("expected peer to be dialed") } } @@ -362,7 +365,7 @@ func (h *prunedBlockDispatcherHarness) assertPeerQueried() { select { case <-h.queriedPeer: - case <-time.After(5 * time.Second): + case <-time.After(defaultTestTimeout): h.t.Fatalf("expected a peer to be queried") } } @@ -395,7 +398,7 @@ func (h *prunedBlockDispatcherHarness) assertPeerReplied( // We need to check the errChan after a timeout because when a request // was successful a nil error is signaled via the errChan and this // might happen even before the block is received. - case <-time.After(5 * time.Second): + case <-time.After(defaultTestTimeout): select { case err := <-errChan: h.t.Fatalf("received unexpected error send: %v", err) @@ -415,7 +418,7 @@ func (h *prunedBlockDispatcherHarness) assertPeerReplied( select { case err := <-errChan: require.NoError(h.t, err) - case <-time.After(5 * time.Second): + case <-time.After(defaultTestTimeout): h.t.Fatal("expected nil err to signal completion") } } @@ -446,7 +449,7 @@ func (h *prunedBlockDispatcherHarness) assertPeerFailed( case err := <-cancelChan: require.ErrorIs(h.t, err, expectedErr) - case <-time.After(5 * time.Second): + case <-time.After(defaultTestTimeout): h.t.Fatalf("expected the error for the block request: %v", expectedErr) } @@ -461,7 +464,7 @@ func (h *prunedBlockDispatcherHarness) assertNoPeerDialed() { select { case peer := <-h.dialedPeer: h.t.Fatalf("unexpected connection established with peer %v", peer) - case <-time.After(2 * time.Second): + case <-time.After(1 * time.Second): } } @@ -482,7 +485,7 @@ func (h *prunedBlockDispatcherHarness) assertNoReply( h.t.Fatalf("received unexpected cancel request with error: %v", err) - case <-time.After(2 * time.Second): + case <-time.After(1 * time.Second): } } diff --git a/docs/developer/README.md b/docs/developer/README.md index 1c3dd1f863..4551496f42 100644 --- a/docs/developer/README.md +++ b/docs/developer/README.md @@ -28,6 +28,15 @@ Best practices for writing clear, effective, and maintainable unit tests. --- +## 🧾 Transaction Invalidation Flows + +A focused guide to the wallet tx-store event model for transaction invalidation, +publisher cleanup, and rollback handling. + +**[➡️ Read the Transaction Invalidation Flows Guide](./tx_invalidation_flows.md)** + +--- + ## 🏛️ Engineering Guide A deep dive into the core design philosophy, architectural patterns, and Go implementation details that guide the development of `btcwallet`. @@ -40,4 +49,12 @@ A deep dive into the core design philosophy, architectural patterns, and Go impl Formal documentation of significant architectural decisions, their context, and consequences. -**[➡️ View Architecture Decision Records](./adr/README.md)** \ No newline at end of file +**[➡️ View Architecture Decision Records](./adr/README.md)** + +--- + +## 📜 PSBT Workflows Guide + +A detailed guide to creating Bitcoin transactions using the `PsbtManager` interface, covering various scenarios and best practices. + +**[➡️ Read the PSBT Workflows Guide](./psbt_workflows.md)** diff --git a/docs/developer/adr/0002-controller-syncer-architecture.md b/docs/developer/adr/0002-controller-syncer-architecture.md new file mode 100644 index 0000000000..46ab623e3a --- /dev/null +++ b/docs/developer/adr/0002-controller-syncer-architecture.md @@ -0,0 +1,51 @@ +# ADR 0002: Controller-Syncer-State Architecture + +## 1. Context + +The legacy `btcwallet` architecture tightly coupled lifecycle management, synchronization logic, and state tracking within a single `Wallet` struct. This monolithic design led to several issues: +* **Race Conditions:** Ambiguity between "Started" and "Syncing" states made it difficult to safely manage concurrent access. +* **Blocking Operations:** Long-running sync operations would block control-plane requests (like `Stop` or `Info`). +* **Testing Difficulty:** The tight coupling made it nearly impossible to unit test synchronization logic in isolation from the full wallet stack. + +We need a robust, testable, and concurrent architecture to support modern features like multi-wallet management and targeted rescans. + +## 2. Decision + +We will adopt a **Controller-Syncer-State** pattern with an **Orthogonal State Model**. + +### 2.1 The Components + +1. **Controller (`Controller` interface / `Wallet` struct):** + * **Role:** The public API surface and lifecycle manager. + * **Responsibility:** Validates requests, manages the `Start/Stop` lifecycle, and delegates long-running tasks. It never blocks on chain operations. + +2. **Syncer (`chainSyncer` interface / `syncer` struct):** + * **Role:** The background worker. + * **Responsibility:** Executes the chain loop, communicates with the backend, and manages the database state for synchronization. It is isolated and testable. + +3. **State (`walletState` struct):** + * **Role:** The source of truth for the wallet's status. + * **Responsibility:** Maintains state across three independent dimensions (Lifecycle, Sync, Auth) using atomic operations. + +### 2.2 Orthogonal State Model + +Instead of a single status enum, we track three separate dimensions: +* **Lifecycle:** `Stopped` -> `Starting` -> `Started` -> `Stopping` +* **Synchronization:** `BackendSyncing` -> `Syncing` -> `Synced` | `Rescanning` +* **Authentication:** `Locked` | `Unlocked` + +## 3. Consequences + +### Pros +* **Concurrency Safety:** State transitions are atomic and explicitly managed, eliminating race conditions. +* **Responsiveness:** The Controller remains responsive to user requests even while the Syncer is performing heavy I/O. +* **Testability:** The `Syncer` can be tested with a mock `Chain` and `Store` without instantiating a full `Wallet`. The `Controller` can be tested with a mock `Syncer`. +* **Clarity:** The separation of concerns makes the codebase easier to navigate and reason about. + +### Cons +* **Complexity:** Increases the number of distinct types and files. +* **Indirection:** Calls to sync functionality now go through a channel-based request mechanism rather than direct method calls. + +## 4. Status + +Accepted and Implemented. diff --git a/docs/developer/adr/0003-optimistic-cfilter-batching.md b/docs/developer/adr/0003-optimistic-cfilter-batching.md new file mode 100644 index 0000000000..fb179b2e65 --- /dev/null +++ b/docs/developer/adr/0003-optimistic-cfilter-batching.md @@ -0,0 +1,54 @@ +# ADR 0003: Optimistic CFilter Batch Scanning + +## 1. Context + +Synchronizing a wallet using BIP 157/158 Compact Filters (CFilters) presents a performance challenge. +* **Latency:** Fetching filters and blocks sequentially (Header -> Filter -> Block) incurs significant network round-trip time (RTT), especially for high-latency backends like Neutrino. +* **The Horizon Problem:** BIP 32 wallets must expand their "lookahead window" (derive new addresses) when used addresses are discovered. If a block contains a transaction to the last address in the window, the wallet must immediately derive more addresses and re-scan subsequent blocks to ensure no funds are missed. + +We need a scanning algorithm that maximizes throughput (minimizing RTT) while guaranteeing correctness (respecting the gap limit). + +## 2. Decision + +We will implement an **Optimistic Batching strategy with In-Place Resume**. + +### 2.1 The Strategy + +1. **Optimistic Fetch:** The wallet fetches headers, CFilters, and (if matched) blocks for a large batch (e.g., 100 blocks) in parallel, assuming the current address lookahead window is sufficient. +2. **Sequential Process:** The downloaded blocks are processed sequentially in memory. +3. **In-Place Resume:** If processing Block `N` triggers a horizon expansion (new addresses derived): + * The processing loop pauses. + * The wallet updates its internal watchlist with the new addresses. + * The wallet **re-scans** the remaining blocks in the *current batch* (Blocks `N+1` to `End`) using the updated watchlist. + * If necessary, it fetches missing blocks that now match the new filters. + +### 2.2 Logic Flow + +``` +Batch Loop: + 1. Fetch Filters for Batch [Start, End] + 2. Match Filters against Current Watchlist + 3. Fetch Matched Blocks + 4. Block Loop (i from Start to End): + a. Process Block(i) + b. If Horizon Expanded: + i. Update Watchlist + ii. Re-Match Filters for [i+1, End] + iii. Fetch Newly Matched Blocks + iv. Continue Loop +``` + +## 3. Consequences + +### Pros +* **High Throughput:** In the common case (no sequential expansion), the wallet fetches data in large, efficient batches, saturating the network connection. +* **Correctness:** The "In-Place Resume" logic guarantees that even if a user receives a chain of payments to sequential addresses in a single batch, the wallet will discover all of them. +* **Efficiency:** It avoids the naive "Stop-and-Go" approach of processing one block at a time, which is prohibitively slow. + +### Cons +* **Complexity:** The resumption logic adds complexity to the scan loop implementation. +* **Redundant Work (Edge Case):** In the worst-case scenario (sequential expansion in every block), the algorithm degrades to re-matching filters repeatedly. However, this is rare in practice. + +## 4. Status + +Accepted and Implemented. diff --git a/docs/developer/adr/0004-targeted-rescan-vs-rewind.md b/docs/developer/adr/0004-targeted-rescan-vs-rewind.md new file mode 100644 index 0000000000..bd4da5944b --- /dev/null +++ b/docs/developer/adr/0004-targeted-rescan-vs-rewind.md @@ -0,0 +1,58 @@ +# ADR 0004: Targeted Rescan vs. Global Rewind + +## 1. Context + +In `btcwallet`, discovering missing transactions has historically required a "Rescan." The legacy implementation treated all rescans as a "Rewind": +1. Set the wallet's global `SyncedTo` height back to the start block. +2. Force the wallet into a `Syncing` state. +3. Re-process all blocks from that height forward. + +This "Global Rewind" approach is problematic for modern use cases like importing a single private key or account. +* **Disruption:** It forces the entire wallet to be "unsynced" for minutes or hours, blocking critical operations like creating transactions, even though the existing keys are perfectly up-to-date. +* **Inefficiency:** It re-scans the chain for *all* wallet addresses, not just the imported ones. + +We need a mechanism to scan for specific keys without disrupting the global wallet state. + +## 2. Decision + +We will implement two distinct types of history recovery, managed by the `Syncer` but differentiated by their effect on the global state. + +### 2.1 Global Rewind (Manual Rescan) +* **Trigger:** Explicit user request via `Resync(...)`. +* **Behavior:** + * **Rewinds** the global `SyncedTo` watermark in the database. + * Sets state to `Syncing`. + * Re-scans for **all** known wallet addresses. +* **Use Case:** Recovering from a corrupted database, a chain reorganization deep in history, or a user explicitly wanting to "reset" the wallet's view. + +### 2.2 Targeted Rescan (Import Scan) +* **Trigger:** Importing keys/accounts (e.g., `ImportPrivateKey`, `ImportAccount`), or a user request with specific targets. +* **Behavior:** + * **Does NOT** rewind the global `SyncedTo` watermark. + * Sets state to a new `Rescanning` sub-state. + * Constructs a **Partial Recovery State** containing *only* the specific targets (addresses/scripts). + * Scans the requested block range for these targets. + * Inserts found transactions into the database. +* **Use Case:** Adding a new key to an existing, synced wallet. + +## 3. Concurrency and Safety + +To prevent race conditions during these operations, we enforce strict access control based on the Orthogonal State Model. + +* **`CreateTransaction` / `FundPsbt`**: Blocked if state is `Syncing` or `Rescanning`. The UTXO set is considered unstable during any scan. +* **`Balance` / `ListUnspent`**: Allowed during `Rescanning`. They return the state of the *existing* (synced) keys, which is safe because the targeted rescan only *adds* new data; it doesn't invalidate existing confirmed history. + +## 4. Consequences + +### Pros +* **User Experience:** Importing a key is a background task. The user can continue to use their existing funds immediately. +* **Performance:** Scanning for 1 key is significantly faster than scanning for 10,000 keys (especially with CFilters). +* **Safety:** Explicitly differentiating the states prevents the "accidental rewind" that scares users. + +### Cons +* **Complexity:** The `Syncer` logic must handle two different "modes" of operation (Global Loop vs. Ad-hoc Job). +* **Database Complexity:** We must ensure that inserting transactions during a targeted rescan doesn't conflict with the global sync loop if they happen to overlap (though the design serializes them in the `chainLoop`). + +## 5. Status + +Accepted and Implemented. diff --git a/docs/developer/adr/0005-no-auto-rescan-on-import.md b/docs/developer/adr/0005-no-auto-rescan-on-import.md new file mode 100644 index 0000000000..52039a3cc8 --- /dev/null +++ b/docs/developer/adr/0005-no-auto-rescan-on-import.md @@ -0,0 +1,45 @@ +# ADR 0005: Explicit Rescan on Import + +## 1. Context + +When importing new keys, addresses, or accounts into a wallet (e.g., via `ImportPrivateKey` or `ImportAccount`), the wallet needs to scan the blockchain history to discover any existing funds associated with these new credentials. + +A common pattern in some wallet implementations is to automatically trigger a rescan immediately upon import. However, this approach introduces several issues: +* **Performance Storms:** If a user or application imports a batch of 100 keys sequentially, an automatic trigger would launch 100 overlapping, redundant rescan jobs. +* **Blocking Behavior:** If the import method waits for the scan, a simple database insertion becomes a potentially hour-long operation. +* **API Ambiguity:** It blurs the line between "State Management" (adding a key) and "Network Operation" (scanning the chain). + +## 2. Decision + +`btcwallet` will **not** automatically trigger a blockchain rescan when keys, addresses, or accounts are imported. + +* **Import Methods are Purely Database Operations:** Methods like `ImportPrivateKey`, `ImportAccount`, and `ImportScript` will only persist the data to the wallet database and return immediately. +* **Rescans Must Be Explicit:** The caller is responsible for explicitly requesting a rescan (via `Rescan(...)`) after the import is complete. + +## 3. Rationale + +### 3.1 Batch Efficiency +This design allows downstream applications (like `lnd` or custom scripts) to batch imports efficiently. An application can import 1,000 keys in a loop and then trigger a **single** targeted rescan for the aggregate birthday of those keys. This is orders of magnitude more efficient than 1,000 individual scans. + +### 3.2 API Clarity +Separating the concerns of "Storage" and "Synchronization" makes the API predictable. +* `ImportXXX`: "I want to save this key." (Fast, Atomic, Synchronous) +* `Rescan`: "I want to look for money." (Slow, Asynchronous, Cancellable) + +### 3.3 User Control +The user (or calling software) retains control over system resources. They may choose to import keys now but defer the heavy scanning operation until a maintenance window or when bandwidth is available. + +## 4. Consequences + +### Pros +* **Performance:** Eliminates redundant scanning during bulk imports. +* **Responsiveness:** Import RPCs remain consistently fast. +* **Flexibility:** Allows advanced import workflows (e.g., offline imports). + +### Cons +* **Usability Pitfall:** A naive user might import a key and be confused why their balance shows `0`. Documentation and RPC output must clearly indicate that a rescan is required to see funds. +* **Client Burden:** Clients must implement the "Import -> Rescan" logic themselves. + +## 5. Status + +Accepted and Implemented. diff --git a/docs/developer/adr/0006-wtxmgr-sql-schema.md b/docs/developer/adr/0006-wtxmgr-sql-schema.md new file mode 100644 index 0000000000..f2e4516eee --- /dev/null +++ b/docs/developer/adr/0006-wtxmgr-sql-schema.md @@ -0,0 +1,255 @@ +# ADR 0006: Wallet Transaction Manager SQL Schema + +## 1. Context + +As part of the migration from a Key-Value store to a relational SQL backend, the Wallet Transaction Manager (`wtxmgr`) requires a new schema design. The `wtxmgr` is responsible for tracking: +1. **Transactions:** Immutable blockchain data and user intent. +2. **UTXOs (Credits):** Outputs owned by the wallet (the primary operational unit). +3. **Spends (Debits):** Inputs that spend those outputs. +4. **Metadata:** User labels and transient locks (leases). + +For a detailed theoretical analysis of the data model, see [UTXO Data Model and Lifecycle](../utxo_data_model.md). + +Note on txid uniqueness: Bitcoin has historical txid-duplicate edge cases (primarily coinbase). However, since BIP30, duplicates cannot create concurrently-unspent outpoints. This schema therefore treats `tx_hash` as unique per wallet for balance/coin selection purposes. + +## 2. Decision + +We will adopt a **UTXO-Centered, Soft-Deletion Schema**. + +### 2.1 Core Principles +1. **UTXO-Centered Operations:** The schema is optimized for `Balance()` and `CoinSelection()` queries, which focus on the `utxos` table. +2. **Transaction-Centered Integrity:** Validity flows from Parent to Child. A UTXO is only valid if its parent Transaction is valid (`tx_status = 1`, `published`) or is explicitly allowed for chaining (`tx_status = 0`, `pending`). +3. **Immutable History (Soft Deletion):** We **NEVER** automatically `DELETE` rows. + * Failed/RBF'd transactions are marked with a `tx_status` field (e.g., `replaced`, `failed`). + * They remain in the database for audit history but are excluded from balance queries. + * Foreign Keys use `ON DELETE RESTRICT` for creation relationships to prevent accidental data loss. +4. **Wallet-Scoped Rows:** All `wtxmgr` tables are scoped by `wallet_id` to support multiple wallets sharing a single database without row-level conflicts. + +### 2.1.1 Wallet-Scoped vs Global Transactions +Two designs were considered: + +* **Wallet-scoped tables (chosen):** + * Pros: Simple schema; no join tables; queries stay local to a wallet; avoids cross-wallet coordination. + * Cons: Duplicates transaction rows across wallets that observe the same global tx. +* **Global transactions table (alternative):** + * Pros: Storage efficiency; one canonical row per `tx_hash`. + * Cons: Requires additional mapping tables (e.g., `wallet_transactions`) for per-wallet ownership/metadata; more complex queries and constraints; more careful concurrency semantics. + +This ADR chooses wallet-scoped tables for simplicity and implementability. A future migration to a global transactions table is possible but is considered a separate architectural decision. + +### 2.1.2 Explicit Status vs. Derived Status +Two designs were considered for tracking transaction validity: + +* **Explicit `tx_status` column (chosen):** + * The `tx_status` field is a pre-computed materialization of the transaction's validity state, set atomically at write time when the invalidating event occurs (RBF, double-spend, reorg). + * It is stored as a compact numeric code (`0 = pending`, `1 = published`, `2 = replaced`, `3 = failed`, `4 = orphaned`) so hot-path predicates and indexes do not pay the storage or comparison cost of repeated status strings. + * The schema intentionally does **not** use a separate status lookup table. The status set is tiny, closed, and application-owned, so a reference table would add foreign-key and seed-data complexity without adding meaningful flexibility. Keeping the code inline on the transaction row preserves hot-path simplicity, while the Go enum layer provides the human-readable names. + * Pros: Balance and coin-selection queries filter on a single status predicate (`tx_status IN (1, 0)`) with no joins (and can be indexed if profiling justifies it); cascading invalidation is performed once at write time and never re-derived; audit states (`failed`, `replaced`, `orphaned`) are directly queryable. + * Cons: Introduces a field that could theoretically drift from the underlying facts. This is partially mitigated by `CHECK` constraints (`check_confirmed_published`, `check_coinbase_confirmation_state`) and coinbase reorg triggers; transition correctness for `failed`/`replaced` remains a write-path responsibility validated by tests. + +* **Derived status from other columns (alternative):** + * At coarse granularity (pending vs active vs invalid), some states are partly derivable: `orphaned` = `is_coinbase AND block_height IS NULL`; direct `replaced` victims can be identified from `tx_replacements.replaced_tx_id`. Direct `failed` victims are not derivable from replacement edges alone because upstream invalidation intentionally records no replacement edge. + * However, a full replacement requires preserving the distinct invalid states (`replaced`, `failed`, `orphaned`), which have different operational semantics: RBF (`replaced`) allows re-spending the same inputs with a new tx; `orphaned` coinbase can recover on reconfirmation; `failed` (double-spend) is permanent. Collapsing these into a single boolean loses information needed for recovery logic and user-facing audit. + * A boolean decomposition (e.g., `is_broadcast` + `is_invalid`) also introduces problems: `is_broadcast` does not equal `published` — a tx can be valid/published because it was received from the network, not broadcast by this wallet; boolean pairs create ambiguous combinations (e.g., `is_broadcast=false, is_invalid=true`) requiring additional constraints. + * A fully-derived alternative that preserves the same information would require at least three columns (`is_broadcast`/source marker, `invalidation_reason` enum, optional `invalidated_by_tx_id`) — strictly more schema surface than a single `tx_status` column. + * Cascading invalidation adds further complexity: downstream txs whose parent became invalid were never directly "replaced." Without a pre-computed status, every balance/coin-selection query would need a recursive CTE walking up the ancestor chain — O(depth) per tx on the hot path. + +This ADR chooses explicit status as the minimal representation that captures all lifecycle states without ambiguity. The `tx_status` column is a pre-computed materialization set once at write time; database constraints/triggers enforce key invariants (confirmation and coinbase semantics), while write-path logic is responsible for setting `failed`/`replaced` transitions correctly. + +### 2.2 Consistency & Concurrency Model +This design assumes standard SQL ACID guarantees. + +* **Atomic updates:** Reorg disconnects, status transitions (RBF/failure/orphaning), and lease acquisition MUST be performed using explicit SQL transactions. +* **Leases are the concurrency primitive:** `utxo_leases` provides an application-level lock that prevents concurrent coin selection from choosing the same UTXO. +* **Isolation:** Wallet implementations SHOULD treat coin selection + lease acquisition as a single atomic unit. If multiple processes share one database, they must rely on database-enforced constraints (primary keys/uniques) and transactional semantics rather than best-effort in-memory coordination. + +Recommended operational defaults: +* Prefer running write paths under `SERIALIZABLE` and retry on serialization failures. +* If using weaker isolation (e.g., `READ COMMITTED`), rely on unique constraints and single-statement lease acquisition (no read-then-write without conflict handling). + +### 2.3 Reorg & RBF Strategy +* **Reorgs:** The `blocks` table represents the current best chain. If a block is disconnected, any referencing transaction will have `block_height` set to `NULL` via `ON DELETE SET NULL`. + * **Effect (non-coinbase):** Regular transactions become unconfirmed (but remain `published`). + * **Coinbase special case:** Coinbase transactions from the disconnected block are marked `orphaned` (they cannot exist outside the block that created them). + * **Atomicity requirement:** The coinbase status update MUST occur atomically with the disconnect. + * PostgreSQL evaluates `CHECK` constraints immediately, including updates performed by foreign-key actions such as `ON DELETE SET NULL`. + * If you enforce the coinbase invariant at the database level, a simple `DELETE FROM blocks ...` will fail unless the coinbase row's `tx_status` is updated to `orphaned` as part of the same statement. + * Recommended: use a trigger to rewrite coinbase `tx_status` during the `block_height -> NULL` update (see 3.5). + * **Reconfirmation:** If an orphaned coinbase transaction re-enters the best chain, restoring it requires setting `block_height` and `tx_status = 1` (`published`) atomically. +* **RBF:** Handled by updating the `utxos.spent_by_tx_id` pointer to the new transaction and marking the old transaction as `replaced`. + +### 2.4 Implementation Notes + +This ADR includes a reference schema. The implementation keeps the same +invariants, but makes a few deliberate schema choices to match the existing +conventions in `wallet/internal/db/migrations/`. + +**Primary keys and wallet scoping** + +The reference schema uses composite primary keys (`(wallet_id, id)`) to make +wallet scoping enforceable with foreign keys. + +In this repository, SQLite tables follow the rowid-backed +`INTEGER PRIMARY KEY` pattern used by the existing wallet/account/address +schema. To preserve the wallet-scoping invariant while keeping that +convention, the implementation uses single-column primary keys on `id` and +adds `UNIQUE(wallet_id, id)` constraints on wallet-scoped tables. Child tables +then use composite foreign keys referencing `(wallet_id, id)`. + +**Manual pruning and `spent_by` semantics** + +The reference schema uses `ON DELETE SET NULL` for the +`(wallet_id, spent_by_tx_id)` foreign key so that physically deleting a +spending transaction can restore a UTXO to the unspent set. + +In a composite foreign key, `ON DELETE SET NULL` applies to *all* referencing +columns. With `utxos.wallet_id` being `NOT NULL`, the reference behavior cannot +be expressed directly as written. + +The implementation therefore uses `ON DELETE RESTRICT` for the spender foreign +key and defines manual pruning as an explicit, application-driven operation +that clears `utxos.spent_by_*` before deleting/pruning the spending +transaction, all within a single SQL transaction. + +**SQLite coinbase disconnect handling** + +PostgreSQL can rely on the transaction-row trigger alone when a block delete +causes `transactions.block_height` to become `NULL`. + +SQLite evaluates child-row checks before an `AFTER UPDATE` trigger on the child +table can normalize the row. To preserve the coinbase orphaning invariant, the +implementation adds a `BEFORE DELETE ON blocks` trigger that rewrites affected +transactions into their final disconnected state before the block row is +removed. + +**Transaction labels** + +User-facing labels are part of the internal store contract (`TxInfo.Label`). +The implementation stores them inline as `transactions.tx_label` instead of a +separate labels table to keep the hot read path simple. + +**Timestamps** + +Both PostgreSQL and SQLite now follow the same timestamp contract: + +- all persisted timestamps represent UTC instants +- PostgreSQL stores them as `TIMESTAMP` +- SQLite stores them as `TIMESTAMP`/`DATETIME` +- logic-sensitive comparisons (for example lease expiry) use caller-supplied UTC + values instead of relying on session-local database time semantics + +## 3. Implemented Schema Notes + +The SQL migrations under `wallet/internal/db/migrations/postgres` and +`wallet/internal/db/migrations/sqlite` are the source of truth for the concrete +schema. This section captures the important design decisions without duplicating +full reference DDL that can drift from the live migrations. + +### 3.1 Transactions + +- `transactions` remains wallet-scoped through `wallet_id` +- `tx_status` stores the wallet-relative validity state inline as a small, + closed numeric enum +- `tx_label` stores user-facing labels inline and keeps the hot read path simple +- `raw_tx` is retained because the schema does not store a fully normalized + input/output graph; callers still need to reconstruct `wire.MsgTx` for + transaction reads and dependency walks +- `UNIQUE (wallet_id, id)` remains so wallet-scoped child relations can use a + composite foreign-key target where needed + +### 3.2 UTXOs + +- `utxos.wallet_id` is intentionally not stored +- wallet ownership is derived from the creating transaction: + `utxos.tx_id -> transactions.wallet_id` +- the owning address is still recorded through `address_id` +- correctness requires the wallet derived from `tx_id` and the wallet derived + from `address_id` to match +- PostgreSQL and SQLite both enforce this invariant with triggers on `utxos` +- same-wallet spend edges are also enforced by trigger when `spent_by_tx_id` is + present + +This keeps the UTXO row normalized while preserving the important wallet-scoped +integrity checks. + +### 3.3 Replacement edges + +- `tx_replacements` remains wallet-scoped +- both endpoints reference wallet-scoped transaction rows through + `(wallet_id, id)` +- `created_at` is stored as a UTC `TIMESTAMP` + +### 3.4 UTXO leases + +- `utxo_leases` keeps `wallet_id` as a query helper for wallet-scoped lease + scans and cleanup +- the row is keyed by `utxo_id`, so one UTXO can have at most one lease row +- wallet consistency between `utxo_leases.wallet_id` and the leased UTXO is + enforced by trigger +- `expires_at` is stored as a UTC `TIMESTAMP` +- lease acquisition, renewal, and cleanup compare against explicit UTC values + supplied by the caller + +### 3.5 Triggers + +The implementation relies on triggers for the invariants that cannot be fully +expressed through ordinary foreign keys alone: + +- coinbase disconnect/orphan handling on `transactions` +- `wallet(tx_id) == wallet(address_id)` on `utxos` +- same-wallet `spent_by_tx_id` on `utxos` +- lease wallet consistency on `utxo_leases` + +This keeps the database responsible for protecting the critical wallet-scoping +rules rather than assuming the application layer is always correct. + +## 4. Consequences + +### 4.1. True Multi-Wallet Support +The transaction graph remains wallet-scoped, and every wallet-owned row is +either directly keyed by `wallet_id` or derives its wallet ownership through a +wallet-scoped parent transaction. This allows multiple wallets to share the +same database without conflicting unique constraints while still permitting the +same network outpoint or tx hash to appear in multiple wallets independently. + +Note: A separate, truly global `transactions` table shared across wallets is a different design. That approach would require a join table (e.g., `wallet_transactions`) to track per-wallet ownership and metadata. + +### 4.2. Native SQL Efficiency +Balances are calculated using `SUM(amount)` over the normalized `utxos` table +with transaction/account joins and database-side filtering, leveraging native +database optimizations. + +The schema intentionally stops short of declaring one canonical "spendable +balance" API because callers may disagree about pending chaining, lease +exclusion, confirmation thresholds, or coinbase maturity rules. + +### 4.3. Audit Trail +By using soft-deletion style transaction states (`tx_status = 2`, `replaced`), +we maintain a complete history of user attempts, even those that failed. This +is superior to previous designs that physically deleted failed transactions. + +### 4.4. Complexity Trade-off +We accept slightly more complexity in **Transaction Reconstruction** (joining inputs/outputs) in exchange for maximal performance in **Balance Calculation** and **Coin Selection**, which are the high-frequency operations. + +Additional operational consequences: +* **Pending-chaining is advanced:** Zero-latency chaining should remain an + application-level choice. Callers that opt into `pending` parents take on + the operational risk that child transactions depend on parents being + broadcast successfully. +* **Recursive invalidation is unbounded in theory:** Marking downstream transactions `failed` after an upstream double-spend can require recursive graph traversal. Implementations should assume bounded typical depth but plan for worst-case behavior (e.g., set a maximum recursion depth or iteration limit). + +### 4.5 Operational Notes (Out of Scope for This ADR) +This ADR defines the target schema and invariants. Production operations still require explicit policies and tooling: + +* **Pruning:** Define criteria for manual pruning (what can be deleted, and how to preserve audit semantics). +* **Migration:** Define how to migrate from the existing key-value store (offline conversion, incremental migration, rollback plan). +* **Backup/Restore:** Large immutable histories impact backup size and restore time; restores may require revalidation of unconfirmed transactions. + * After restore, the wallet should re-fetch the current mempool and re-evaluate RBF/double-spend status for unconfirmed transactions. +* **Schema evolution:** Future changes should be delivered via versioned migrations; preserve immutable-history guarantees when introducing new columns or constraints. + +Monitoring note: +* Track table growth, count of active leases, and lease cleanup latency. These are important for long-running nodes and multi-process deployments. + +## 5. Status + +Accepted. diff --git a/docs/developer/adr/0007-xchacha20-poly1305-encryption.md b/docs/developer/adr/0007-xchacha20-poly1305-encryption.md new file mode 100644 index 0000000000..3e64dcf051 --- /dev/null +++ b/docs/developer/adr/0007-xchacha20-poly1305-encryption.md @@ -0,0 +1,99 @@ +# ADR 0007: XChaCha20-Poly1305 Encryption + +## 1. Context + +As part of the broader effort to simplify wallet encryption to a single +passphrase model, we are revisiting the cryptographic primitives used to +protect sensitive data. Today, `btcwallet` relies on NaCl secretbox, based on +XSalsa20-Poly1305, to encrypt private key material. + +While XSalsa20-Poly1305 remains secure, XChaCha20-Poly1305 is now the +preferred successor in modern systems. It preserves the large nonce model +while offering better library support and alignment with current AEAD +standards. + +Key motivations for this change include: + +* Broad adoption of XChaCha20-Poly1305 in modern cryptographic systems +* Strong performance on systems without AES-NI, including mobile and embedded + environments +* Simpler and more uniform AEAD construction, reducing implementation risk +* Easier auditing due to reduced complexity +* Production-proven usage in the Lightning Network's encrypted transport + protocol ([BOLT + 8](https://github.com/lightning/bolts/blob/master/08-transport.md)), + demonstrating its suitability for Bitcoin-related projects + +Like XSalsa20, XChaCha20-Poly1305 uses a 192-bit nonce, which allows random +nonce generation without practical collision risk and matches our existing +nonce strategy. + +```mermaid +flowchart TD + A[User Passphrase] --> B[scrypt KDF] + B --> C[Master Key] + C --> D[XChaCha20-Poly1305] + D --> E[Encrypted Private Data] +``` + +## 2. Decision + +We will migrate the encryption primitive from NaCl secretbox, based on +XSalsa20-Poly1305, to **XChaCha20-Poly1305**. + +### Key aspects + +1. **Encryption primitive** + Use XChaCha20-Poly1305 from `golang.org/x/crypto/chacha20poly1305`, + specifically the XChaCha variant with 192-bit nonces. + +2. **Key hierarchy** + Preserve the existing structure: scrypt-based KDF, master key derivation, + and per-use encryption keys. + +3. **Data separation** + Continue storing public data in plaintext while encrypting private or + sensitive material only. + +4. **Migration path** + Encrypted data will be re-encrypted during the SQL migration process. + +## 3. Consequences + +### Pros + +* **Performance** + Strong performance in pure software environments, especially where AES-NI + is unavailable. + +* **Modern AEAD** + Aligns the codebase with current best practice for authenticated encryption. + +* **Lower implementation risk** + Fewer edge cases compared to AES-GCM or custom constructions. + +* **Auditability** + Smaller and clearer code paths improve reviewability and long-term + maintenance. + +* **Nonce safety** + The 192-bit nonce space allows safe random nonce generation and preserves + compatibility with existing designs. + +### Cons + +* **Migration cost** + All encrypted data must be re-encrypted during migration. + +* **Temporary dual support** + XSalsa20-Poly1305 must remain supported until migration is complete. + +* **Testing overhead** + Both encryption paths require validation during the transition period. + +* **Passphrase dependency** + Re-encryption is only possible when the user passphrase is available. + +## 4. Status + +Proposed. diff --git a/docs/developer/adr/0008-integration-test-framework.md b/docs/developer/adr/0008-integration-test-framework.md new file mode 100644 index 0000000000..515854d2e4 --- /dev/null +++ b/docs/developer/adr/0008-integration-test-framework.md @@ -0,0 +1,121 @@ +# ADR 0008: Integration Test Framework + +## 1. Context + +The `btcwallet` project requires a robust integration testing framework to verify the correctness of its core `wallet` package against various configurations. The current testing landscape is insufficient for verifying the complex matrix of supported backends and modes. + +There is a requirement to support: +1. **Multiple Chain Backends**: `btcd` (native), `bitcoind` (external process), and `neutrino` (SPV). +2. **Multiple Database Backends**: `kvdb` (bbolt/etcd), `sqlite`, and `postgres`. +3. **Public API Testing**: Verifying the public methods of the `wallet` package (e.g., `Create`, `Load`, `Start`, `Stop`). + +We need a standardized, reusable approach to write integration tests that can run across all these permutations without duplicating setup logic. + +## 2. Decision + +We will implement a modular integration test framework modeled after `lnd`'s `lntest`, adapted for library-mode testing. + +### 2.1. Library-Mode Testing +Tests will run `btcwallet` in **Library Mode** (in-process). +- **Why**: Faster execution, easier debugging, and direct internal state assertion. +- **How**: The test harness instantiates `wallet.Manager` directly. + +### 2.2. Architecture & Components + +The framework is split into `bwtest` (framework) and `itest` (test cases). + +#### Component Interaction + +```mermaid +graph TD + subgraph "Test Execution (itest)" + Test["Test Function (t.Run)"] + Harness["HarnessTest (Instance)"] + end + + subgraph "Infrastructure (bwtest)" + Miner["Miner (btcd)"] + ChainNode["Chain Node Process"] + DB["Database Backend"] + end + + subgraph "Application Under Test" + CB["ChainBackend (Interface Wrapper)"] + W["Wallet (In-Memory)"] + end + + Test -->|Creates & Owns| Harness + Harness -->|Starts| Miner + Harness -->|Starts| ChainNode + Harness -->|Initializes| DB + Harness -->|Configures| CB + Harness -->|Creates| W + + ChainNode -->|"P2P Sync"| Miner + CB -->|"RPC / P2P"| ChainNode + W -->|"Uses (chain.Interface)"| CB + W -->|"Stores Data"| DB +``` + +* **`Miner`**: A dedicated `btcd` instance responsible for generating blocks. It acts as the source of truth for the blockchain state. +* **`Chain Node`**: The software providing chain data (`btcd`, `bitcoind`). + * For `btcd` tests: A separate `btcd` process is started as the Chain Node and connects to the Miner. + * For `bitcoind` tests: A separate `bitcoind` process connects (P2P) to the Miner to sync. + * For `neutrino` tests: There is no separate "Chain Node" process; Neutrino connects directly to the Miner. +* **`ChainBackend`**: The interface wrapper (`rpcclient` or `neutrino.ChainService`) used by the Wallet to communicate with the Chain Node. +* **`Database Backend`**: The storage layer (`kvdb`, `postgres`, `sqlite`). +* **`HarnessTest`**: The specific test instance. It manages unique ports, temp directories, and process lifecycles. + +### 2.3. Package Structure + +- **`bwtest/`**: + - `harness.go`: `HarnessTest` orchestrator. Manages `t.Cleanup` for resource teardown. + - `chain_backend.go`: Logic to start/stop `btcd`/`bitcoind` processes and configure `neutrino`. + - `miner.go`: `Miner` implementation (controls the primary `btcd` instance). + - `database.go`: Helpers to setup/teardown DBs (create temp bolt files, init SQL schemas). +- **`itest/`**: + - `main_test.go`: Flag parsing (`-chain`, `-db`) and global test runner. + - `manager_test.go`: Test cases for `wallet.Manager`. + +### 2.4. Configuration & Isolation + +Configuration is handled via `go test` flags. + +```bash +# Default (btcd + kvdb) +make itest + +# Explicit configuration +make itest chain=bitcoind db=postgres + +# Run specific test case +make itest case=TestNewWallet +``` + +* **Sequential Execution**: Tests run sequentially to avoid resource exhaustion and port conflicts, given the heavy overhead of spinning up multiple full nodes per test. +* **Neutrino Support**: The `Miner` (btcd) will be configured with `--cfilters` to serve compact block filters, allowing Neutrino clients to connect directly to it for SPV synchronization. + +## 3. Implementation Plan + +1. **Scaffold Framework (`bwtest`)**: + - Implement `Miner` (wraps `rpctest`). + - Implement `ChainBackend` logic (start bitcoind/btcd process, setup neutrino). + - Implement `Database` setup (init postgres schema, temp sqlite files). + - Implement `HarnessTest` to orchestrate the dependency graph: + `Miner -> ChainNode -> ChainBackend -> DB -> Wallet`. +2. **Scaffold Tests (`itest`)**: + - Create `manager_test.go` as a proof-of-concept. +3. **CI Integration**: + - Update `Makefile` with `itest` targets mapping flags correctly. + +## 4. Consequences + +### Pros +- **Consistency**: Clear separation between Network, Infrastructure, and Application. +- **Isolation**: Per-test harnesses prevent state leaks. +- **Coverage**: capable of validating the entire support matrix. + +### Cons +- **Resource Intensity**: Running a separate `bitcoind`/`btcd` process per test is CPU/RAM intensive. +- **Complexity**: Dynamic port allocation and process lifecycle management are error-prone. +- **Execution Time**: Tests will take longer to run due to sequential execution and process startup costs. diff --git a/docs/developer/adr/0009-single-passphrase-encryption.md b/docs/developer/adr/0009-single-passphrase-encryption.md new file mode 100644 index 0000000000..4bc2431205 --- /dev/null +++ b/docs/developer/adr/0009-single-passphrase-encryption.md @@ -0,0 +1,75 @@ +# ADR 0009: Single-Passphrase Encryption Model + +## 1. Context + +`btcwallet` is migrating from a key-value database (kvdb) to a SQL-based +backend. This transition requires a clean, consistent encryption model that +works across SQLite and PostgreSQL. + +The legacy system used a dual-passphrase hierarchy with separate public and +private encryption keys. In practice, downstream systems often set the public +passphrase to a known constant to allow auto-start, which negated the privacy +benefits while keeping the complexity. This model also diverges from the +industry standard set by Bitcoin Core and libraries like BDK. + +The design notes that guided this change are captured in +[Single-Passphrase Encryption Design Notes](https://gist.github.com/yyforyongyu/edfeee0f84cf79851735bcc3e740a871). + +## 2. Decision + +We will adopt a **Single-Passphrase Model** that encrypts **private data only** +and leaves public data in plaintext. + +```mermaid +flowchart TD + A[User Passphrase] --> B[scrypt KDF] + B --> C[Master Key] + C --> D[Encrypt Private Data] + E[Public Data] --> F[(Database)] + D --> F +``` + +### Key points + +1. **One master encryption key** derived from the user passphrase +2. **Private data encrypted** (private keys, HD seeds, xprivs, scripts) +3. **Public data plaintext** (addresses, pubkeys, transactions, balances) +4. **Simplified hierarchy** by removing `CryptoPubKey` and `CryptoScriptKey` + +## 3. Consequences + +### Pros + +* **Simplicity**: Eliminates dual passphrase logic. +* **Performance**: Read-only operations no longer require decrypting public + data, reducing query overhead. +* **Interoperability**: Aligns with Bitcoin Core and BDK conventions. +* **UX clarity**: Users only need a single passphrase. + +### Cons + +* **Privacy at rest**: Transaction history, balances, and public keys are + exposed if the database is compromised, consistent with Bitcoin Core tradeoffs. + If stronger protection is required, users can rely on full disk encryption such + as LUKS. +* **Migration effort**: Existing wallets must be migrated away from the legacy + dual passphrase model. This work will be done along with the SQL backend migration. + +## 4. Threat Model and Security Boundaries + +This model protects private key material at rest when an attacker can read the +database but cannot guess the wallet passphrase. + +This model does not protect metadata privacy if the database contents are +exfiltrated. Addresses, balances, and transaction history remain visible by +design. + +Operational mitigations required for this model: + +* Full disk encryption for database files. +* Host hardening (least privilege, patching, endpoint protection). +* Encrypted backups with independent access controls and key management. + +## 5. Status + +Accepted. diff --git a/docs/developer/adr/0010-keyvault-encryption-layer.md b/docs/developer/adr/0010-keyvault-encryption-layer.md new file mode 100644 index 0000000000..0b7f48fd86 --- /dev/null +++ b/docs/developer/adr/0010-keyvault-encryption-layer.md @@ -0,0 +1,108 @@ +# ADR 0010: Keyvault Encryption Layer + +## 1. Context + +The updated encryption model defined in ADR 0009 and the planned +cryptographic primitive migration proposed in ADR 0007 require a clear +boundary between domain logic and the SQL database layer. The +legacy `waddrmgr` design tightly couples storage, locking, and key derivation, +which complicates the SQL migration and makes encryption behavior hard to test +in isolation. + +To address this, we need a dedicated component that owns lock state, key +derivation, and encryption, while keeping the database layer strictly +encryption agnostic. + +The `db.Store` remains available to other callers for non-cryptographic +queries and updates. + +## 2. Decision + +We will introduce a dedicated **`wallet/internal/keyvault`** package that +defines the encryption boundary between domain code and the SQL store layer. +Keyvault accesses the database through `db.Store`, not by talking directly +to the SQL backend. + +```mermaid +flowchart TD + A[Domain Code] -->|crypto ops| B[keyvault] + A -->|non-crypto ops| E[db.Store] + B -->|derive| C[btcutil/hdkeychain] + B -->|decrypt/encrypt| F[XChaCha20Poly1305
XSalsa20Poly1305] + B -->|read/write| E + B -->|read/write| H[(memory cache)] + E --> D[(SQL DB)] + E --> G[(kvdb)] + classDef store fill:#f8f9fb,stroke:#9aa0a6,stroke-width:1px + class G,D store +``` + +### Responsibilities + +1. **Own lock state and key lifecycle** + Centralized management of unlock state, key derivation, key material + lifetime, and secure memory zeroing. + +2. **Expose typed domain interfaces** + Methods return `*btcec.PrivateKey`, `*btcec.PublicKey`, and + `btcutil.Address` instead of encrypted `[]byte` values. + +3. **Handle HD derivation** + Use `btcutil/hdkeychain` for BIP32 and BIP44 derivation and return or + persist derived keys as needed. + +4. **Maintain an in-memory cache** + Cache account level and derived keys to avoid repeated derivation and + database reads. + +5. **Support multi-wallet operation** + A single keyvault instance manages multiple wallets via `wallet_id` + parameters. + +6. **Track current and planned cryptographic primitives** + Encryption follows the accepted single-passphrase model in ADR 0009 and + adopts ADR 0007 once the XChaCha20-Poly1305 migration is implemented. + +7. **Coexist with `waddrmgr` during migration** + New code uses keyvault while legacy code continues to rely on + `waddrmgr` until the migration is complete. + +## 3. Consequences + +### Pros + +* **Separation of concerns** + Database code stores opaque bytes without knowledge of cryptography or lock + state. + +* **Type safety** + Callers work with strongly typed keys rather than raw blobs. + +* **Centralized lock management** + Prevents lock state divergence across components. + +* **Performance** + Caching reduces repeated derivation and database access. + +* **Testability** + Keyvault can be tested against mock databases, and domain logic can be + tested using mock keyvault implementations. + +* **Memory safety** + Secure memory handling and zeroing are centralized in one place. + +### Cons + +* **Additional abstraction** + Introduces a new package boundary that must be maintained. + +* **Migration cost** + Existing code paths must be refactored to use the new keyvault API. + +* **Temporary dual systems** + `waddrmgr` and keyvault will coexist during the transition period, + increasing short-term complexity. + +## 4. Status + +Accepted. diff --git a/docs/developer/adr/README.md b/docs/developer/adr/README.md index 90cbbf1959..3b569e8123 100644 --- a/docs/developer/adr/README.md +++ b/docs/developer/adr/README.md @@ -6,4 +6,13 @@ ADRs serve as a historical log of important design choices, providing context fo ## Existing ADRs -* [ADR 0001: Multi-Wallet Architecture](./0001-multi-wallet-architecture.md) +- [ADR 0001: Multi-Wallet Architecture](./0001-multi-wallet-architecture.md) - Decides on the architecture for managing multiple distinct wallets and networks within a single daemon instance. +- [ADR 0002: Controller-Syncer-State Architecture](./0002-controller-syncer-architecture.md) - Decouples lifecycle management, synchronization logic, and state tracking from the monolithic `Wallet` struct. +- [ADR 0003: Optimistic CFilter Batch Scanning](./0003-optimistic-cfilter-batching.md) - Optimizes BIP 157/158 Compact Filter synchronization using optimistic batch scanning. +- [ADR 0004: Targeted Rescan vs. Global Rewind](./0004-targeted-rescan-vs-rewind.md) - Introduces "Targeted Rescans" to replace global "Rewinds" for more efficient transaction discovery. +- [ADR 0005: Explicit Rescan on Import](./0005-no-auto-rescan-on-import.md) - Disables automatic blockchain scanning during import operations, requiring explicit user initiation. +- [ADR 0006: Wallet Transaction Manager SQL Schema](./0006-wtxmgr-sql-schema.md) - Defines the relational SQL schema for the Wallet Transaction Manager (`wtxmgr`) migration. +- [ADR 0007: XChaCha20-Poly1305 Encryption](./0007-xchacha20-poly1305-encryption.md) - Replaces XSalsa20-Poly1305 with XChaCha20-Poly1305 for encrypting private key material. +- [ADR 0008: Integration Test Framework](./0008-integration-test-framework.md) - Defines a modular integration test framework for chain and database backend permutations. +- [ADR 0009: Single-Passphrase Encryption Model](./0009-single-passphrase-encryption.md) - Adopts a single-passphrase model that encrypts private data only while keeping public wallet metadata in plaintext. +- [ADR 0010: Keyvault Encryption Layer](./0010-keyvault-encryption-layer.md) - Defines an in-memory keyvault boundary for lock state, key lifecycle, and encryption orchestration between domain logic and SQL persistence. diff --git a/docs/developer/psbt_workflows.md b/docs/developer/psbt_workflows.md new file mode 100644 index 0000000000..b77d635017 --- /dev/null +++ b/docs/developer/psbt_workflows.md @@ -0,0 +1,328 @@ +# PSBT Workflows Guide + +This document provides a guide to creating Bitcoin transactions using the +`PsbtManager` interface. We will explore several scenarios, from a simple +single-person payment to a more complex, multi-party collaborative transaction, +highlighting best practices for security and efficiency. + +Our actors: +- **Alice**: A user of `btcwallet`. +- **Bob**: Another user of `btcwallet`. +- **Carol**: The recipient of the payments. + +--- + +## Scenario 1: Simple Single-Signer Transaction (Alice Pays Carol) + +This is the most common use case: a single user creating a transaction from their +own wallet. The workflow is linear and straightforward. + +**Goal:** Alice wants to pay 1 BTC to Carol. + +```mermaid +flowchart LR + Start([Start]) --> Create["Create Bare PSBT"] + Create --> Fund["Fund PSBT
(Coin Selection)"] + Fund --> Sign["Sign PSBT"] + Sign --> Finalize["Finalize PSBT"] + Finalize --> Broadcast["Broadcast TX"] + Broadcast --> End([Done]) +``` + +### Workflow Steps + +1. **Create a Bare PSBT:** Alice's application first creates a bare PSBT that + describes the intended output. + + ```go + import "github.com/btcsuite/btcwallet/wallet" + + carolOutput := &wire.TxOut{Value: 100_000_000, PkScript: carolPkScript} + packet, err := wallet.CreatePsbt(nil, []*wire.TxOut{carolOutput}) + ``` + +2. **Fund the PSBT:** Alice's wallet performs coin selection to add inputs and a + change output. + + ```go + fundIntent := &wallet.FundIntent{ + Packet: packet, + Policy: &wallet.InputsPolicy{ + Source: &wallet.ScopedAccount{ + AccountName: "default", + KeyScope: waddrmgr.KeyScopeBIP0086, + }, + MinConfs: 1, + }, + FeeRate: btcunit.NewSatPerKVByte(1000), // e.g., 1 sat/vb + } + + fundedPacket, _, err := aliceWallet.FundPsbt(ctx, fundIntent) + + ``` + + The `fundedPacket` now contains the necessary inputs (fully decorated) and a change output. + +3. **Sign the PSBT:** The wallet signs all inputs it has the keys for. + + ```go + signParams := &wallet.SignPsbtParams{Packet: packet} + _, err = aliceWallet.SignPsbt(ctx, signParams) + ``` + +4. **Finalize and Broadcast:** Alice finalizes the PSBT to produce a complete, + valid transaction and broadcasts it. + + ```go + err = aliceWallet.FinalizePsbt(ctx, packet) + finalTx, err := psbt.Extract(packet) + err = aliceWallet.Broadcast(ctx, finalTx, "Payment to Carol") + ``` + +### Analysis + +- **Round Trips:** 0 (all operations are local to Alice's wallet). +- **Security:** High. Alice controls the entire process, so there is no risk + of external manipulation. + +--- + +## Scenario 2: Collaborative Transaction (Alice and Bob Pay Carol) + +This is a more advanced workflow where multiple parties contribute inputs to a +single transaction. This requires careful coordination to ensure security. + +**Goal:** Alice and Bob want to jointly pay Carol. + +We will explore two models for this: a naive (and insecure) model, and the +recommended, secure Coordinator Model. + +### The Naive (and Insecure) Independent Funding Model - **DO NOT USE** + +In this model, participants create their contributions independently and a +coordinator merges them. + +1. **Alice Funds:** Alice creates a PSBT that pays Carol her portion. + `aliceWallet.FundPsbt(...)` -> `packet_alice` +2. **Bob Funds:** Bob does the same. `bobWallet.FundPsbt(...)` -> `packet_bob` +3. **Coordinator Combines:** A coordinator merges these. + `combinedPacket, _ := wallet.CombinePsbt(ctx, packet_alice, packet_bob)` +4. **Signing:** The `combinedPacket` is passed around for signatures. + +#### Security Concerns: Critical Flaw + +This model is **dangerously insecure** in a trustless environment. + +Imagine a malicious Bob. When creating `packet_bob`, he could add an extra, +unexpected output that pays some of the transaction's value to himself. + +When the coordinator calls `CombinePsbt`, this malicious output is merged into +the final transaction. If Alice's application logic does not manually parse and +validate every single input and output from Bob's PSBT fragment, she will +unknowingly sign a transaction that steals funds. **The API makes the insecure +path easy.** + +### The Recommended (and Secure) Coordinator Model + +This model ensures security by having all participants agree on the final +transaction structure *before* any signatures are created. + +**Principle:** Verify the whole transaction, then sign. + +```mermaid +sequenceDiagram + participant A as Alice (Coordinator) + participant B as Bob + participant N as Bitcoin Network + + Note over A,B: 1. Off-chain Agreement on Terms + + A->>A: Create Bare PSBT (Template) + A->>B: Send Bare PSBT + + par Parallel Signing + A->>A: Verify, Decorate, & Sign Input + B->>B: Verify, Decorate, & Sign Input + end + + B->>A: Send Partially Signed PSBT + A->>A: Combine Signatures + A->>A: Finalize Transaction + A->>N: Broadcast Transaction +``` + +#### Workflow Steps + +**1. Agreement (Off-chain)** +Alice and Bob first communicate and agree on the exact transaction: +- Which UTXO Alice will contribute (`alice_utxo_1`). +- Which UTXO Bob will contribute (`bob_utxo_1`). +- The final, combined output for Carol. +- The exact change output for Alice. +- The exact change output for Bob. +- The agreed-upon fee rate. + +**2. Coordinator Creates the Template (Alice)** +Alice, acting as coordinator, creates a single, bare PSBT that represents the +**entire, final transaction**. This is the "single source of truth". + +```go +// Alice's code (as coordinator) +allInputs := []*wire.OutPoint{&alice_utxo_1, &bob_utxo_1} +allOutputs := []*wire.TxOut{carol_output, alice_change_output, bob_change_output} + +// Create a single PSBT template for the entire transaction. +barePacket, err := wallet.CreatePsbt(allInputs, allOutputs) +``` + +**3. Participants Verify, Decorate, and Sign (Parallel)** +The coordinator sends the `barePacket` to all participants (including herself). +Each participant now performs the same set of actions independently. + +```go +// Bob's code (Alice does the same with her wallet) + +// CRITICAL STEP: Verify the transaction structure. +// Bob's application logic MUST inspect barePacket to ensure it exactly +// matches the off-chain agreement. It checks that only the expected inputs +// and outputs are present, with the correct values. +if !isValid(barePacket) { + return errors.New("transaction proposal is invalid") +} + +// If valid, Bob's wallet decorates its own input. +err := bobWallet.DecorateInputs(ctx, barePacket, true) +// The wallet finds bob_utxo_1 and adds its UTXO/derivation info. + +// Bob's wallet now signs its input. +signParams := &wallet.SignPsbtParams{Packet: barePacket} +_, err = bobWallet.SignPsbt(ctx, signParams) + +// Bob sends the partially signed PSBT back to the coordinator. +``` + +**4. Coordinator Combines Signatures (Alice)** +Alice collects the signed PSBTs from all participants. Each PSBT is a copy of +the original `barePacket` but now contains a different partial signature. She +uses `CombinePsbt` to merge these signatures into a single, fully-signed PSBT. + +```go +// Alice's code (as coordinator) +// (Alice has already signed her own copy, `my_signed_packet`) +fullySignedPacket, err := aliceWallet.CombinePsbt( + ctx, my_signed_packet, signed_packet_from_bob, +) +``` + +**5. Finalize and Broadcast (Alice)** +The coordinator now has a complete PSBT and can finalize it to produce the +broadcastable transaction. + +```go +err = aliceWallet.FinalizePsbt(ctx, fullySignedPacket) +finalTx, err := psbt.Extract(fullySignedPacket) +err = aliceWallet.Broadcast(ctx, finalTx, "Collaborative payment to Carol") +``` + +#### Analysis + +- **Round Trips:** 2. + 1. Coordinator distributes the `barePacket` to all participants. + 2. Participants return their signed PSBTs to the coordinator. +- **Security:** High. The security comes from the "verify-then-sign" workflow. + Each participant validates the entire, final transaction structure *before* + creating a signature. A signature becomes a cryptographic commitment to the + complete, agreed-upon transaction, preventing any party from maliciously + altering it after the fact. + +--- + +## Scenario 3: Taproot Script Path Multisig (Signing Multiple Times) + +Taproot introduces powerful new capabilities, such as Script Path spends where +multiple parties can sign via different leaf scripts or a single script requiring +multiple signatures (e.g., a 2-of-2 multisig script leaf). + +The `SignPsbt` method enforces a **strict single-derivation-path policy** per +input call. This means if a wallet holds multiple keys involved in a multisig +input, it must call `SignPsbt` multiple times—once for each key it intends to +sign with. + +**Goal:** A 2-of-2 multisig input (Alice + Bob) is being spent via a Taproot +Script Path. Alice holds both Key A1 and Key A2 (e.g., for redundancy or testing) +and needs to provide two signatures for the same input. + +### Workflow Steps + +1. **Prepare the PSBT:** The coordinator constructs the PSBT with the Taproot + input. The input MUST include the `TaprootLeafScript` and `ControlBlock` to + identify the script path being spent. + +2. **First Signing Pass (Key A1):** Alice's wallet inspects the PSBT. To sign + with Key A1, the application must ensure the PSBT input contains the + `TaprootBip32Derivation` for **Key A1 only**. + + ```go + // Populate derivation info for Key A1 ONLY. + packet.Inputs[0].TaprootBip32Derivation = []*psbt.TaprootBip32Derivation{ + derivInfoForKeyA1, + } + + // Sign. The wallet sees one derivation path and generates one signature. + // It appends this signature to the `TaprootScriptSpendSig` list. + signedResult, err := aliceWallet.SignPsbt(ctx, &wallet.SignPsbtParams{ + Packet: packet, + }) + ``` + +3. **Second Signing Pass (Key A2):** Now Alice needs to sign with Key A2. The + application updates the PSBT input to show the derivation for **Key A2**. + + ```go + // Replace derivation info with Key A2. + packet.Inputs[0].TaprootBip32Derivation = []*psbt.TaprootBip32Derivation{ + derivInfoForKeyA2, + } + + // Sign again. The wallet sees a new, single derivation path. + // It generates the second signature and appends it to the list. + // The previous signature for Key A1 is preserved. + signedResult2, err := aliceWallet.SignPsbt(ctx, &wallet.SignPsbtParams{ + Packet: packet, + }) + ``` + +### Why this Restriction? + +Enforcing a single derivation path per call eliminates ambiguity. +- If `SignPsbt` received multiple derivation paths for one input, it would be + unclear if the caller intended to sign *all* of them, *one* of them, or if + some were just metadata. +- By requiring explicit, singular intent, the API ensures deterministic + behavior: "Here is the key I want you to use; sign with it." + +--- + +## Advanced Topics & Best Practices + +### Why `SIGHASH_ALL` is Essential +In collaborative transactions, all signatures should use `SIGHASH_ALL` (the +default). This flag ensures that the signature commits to *all* inputs and *all* +outputs in the transaction. If a participant were to use a different flag like +`SIGHASH_SINGLE`, a malicious coordinator could modify the parts of the +transaction not covered by the signature, leading to fund loss or unexpected +behavior. + +### The Role of `DecorateInputs` +`DecorateInputs` is the bridge between a transaction's structure and its +signability. In the Coordinator Model, it's a crucial step that allows each +participant's wallet to add the private information (UTXO value, script, +derivation path) needed for its own hardware or software to produce a valid +signature. + +### Coin Control +A user can choose a specific UTXO to spend by creating a PSBT with that input +already included before calling `FundPsbt`. The `FundPsbt` method will detect +the existing input and enter a "completion" mode, where it simply calculates +fees and adds a change output, rather than performing automatic coin selection. +This is how Alice specified her input in the Coordinator Model example. \ No newline at end of file diff --git a/docs/developer/scanning_sync_architecture.md b/docs/developer/scanning_sync_architecture.md new file mode 100644 index 0000000000..ca6a5111e5 --- /dev/null +++ b/docs/developer/scanning_sync_architecture.md @@ -0,0 +1,167 @@ +# Wallet Synchronization and Scanning Architecture + +This document details the architecture of the `btcwallet` synchronization subsystem. It explains how the wallet maintains consensus with the blockchain, discovers relevant transactions, and manages the recovery of funds. + +## 1. High-Level Architecture + +The synchronization system is designed around a **Controller-Worker-State** pattern, separating the public API from the background work and the core logic. + +```mermaid +graph TD + User[User / RPC] -->|Calls Start/Rescan/Unlock| Controller + + subgraph "Wallet Package" + Controller[Controller] -->|Manages| State[Wallet State] + Controller -->|Sends Req| Syncer[Syncer] + + Syncer -->|Maintains| RecoveryState[Recovery State] + Syncer -->|Reads/Writes| DB[(Wallet DB)] + Syncer -->|Fetches Data| Chain[Chain Backend] + end +``` + +### 1.1 Key Components + +* **Controller (`wallet/controller.go`)**: The public face of the wallet. It manages the wallet's lifecycle (`Start`, `Stop`), handles authentication (`Lock`, `Unlock`), and acts as the gatekeeper for state transitions. It does *not* perform blocking chain operations directly. +* **Syncer (`wallet/syncer.go`)**: A dedicated background worker responsible for the main synchronization loop. It communicates with the chain backend (e.g., `bitcoind`, `neutrino`), orchestrates batch scanning, and handles blockchain reorganizations (rollbacks). +* **RecoveryState (`wallet/recovery.go`)**: A specialized state machine that encapsulates the logic for *what* to scan for. It manages BIP32 derivation horizons, address lookahead windows, and the set of watched outpoints. It is purely logic and memory-based, decoupled from the I/O mechanisms of the Syncer. + +--- + +## 2. State Management: The Orthogonal Model + +To manage concurrency and API availability safely, the wallet employs an **Orthogonal State Model**. Instead of a single monolithic status (e.g., "Syncing"), we track three independent dimensions of state. This decoupling allows for precise representation of complex conditions (e.g., a wallet can be "Started" AND "Syncing" AND "Locked") without state explosion. + +### 2.1 Lifecycle (System State) +Tracks the runtime status of the wallet's main event loop and background processes. +* **Stopped**: The wallet is idle. No background routines are running. +* **Starting**: The wallet is in the middle of its synchronous startup sequence (e.g., loading accounts, verifying birthday). +* **Started**: The wallet is fully operational. `mainLoop` and `chainLoop` are running. +* **Stopping**: A shutdown signal has been sent; the wallet is waiting for background routines to exit. + +### 2.2 Synchronization (Chain State) +Tracks data freshness relative to the blockchain backend. +* **BackendSyncing**: Waiting for the chain backend (e.g., bitcoind) to finish its own synchronization. +* **Syncing**: The wallet is actively downloading blocks or filters to catch up to the chain tip. +* **Synced**: The wallet is fully caught up with the current chain tip. +* **Rescanning**: The wallet is performing a targeted historical scan for specific accounts or addresses. This is a sub-state that does not rewind the global sync watermark. + +### 2.3 Authentication (Security State) +Tracks the accessibility of sensitive private key material. +* **Locked**: Private keys are encrypted and inaccessible in memory. +* **Unlocked**: Private keys have been decrypted and are available for signing. +* **Security Note**: The system tracks the `unlocked` flag such that the zero-value (false) defaults to the secure **Locked** state. The wallet is forcefully locked during any `Stop` or `Stopping` transition. + +--- + +## 3. Synchronization Modes + +The `Syncer` operates in two primary modes: + +### 3.1 Chain Synchronization (Global Sync) +This is the default background process that ensures the wallet maintains consensus with the blockchain. + +* **Goal**: Advance the global `SyncedTo` pointer to the current chain tip. +* **Mechanism**: Sequential forward scanning of block batches. +* **Persistence**: Upon successful completion of a batch, the wallet updates its global "sync tip" in the database. + +### 3.2 Targeted Rescan (Import Scanning) +Triggered by user actions like importing a new account, a private key, or an XPUB. + +* **Goal**: Discover historical transactions for the *newly added* keys without affecting the synchronization status of existing keys. +* **Mechanism**: Ad-hoc scanning of a specific block range (typically from the birthday of the imported key to the current tip). +* **Persistence**: Found transactions are inserted into the database, but the global `SyncedTo` watermark is **not** altered. This allows the wallet to remain "Synced" for the rest of its keys while processing the import in the background. + +--- + +## 4. Data Preparation + +Before scanning can begin, the Syncer must prepare a `RecoveryState` object. This object acts as the "Checklist" of things to look for in the blocks. The source of this data depends on the sync mode. + +### 4.1 Loading for Global Sync +When performing the standard chain sync, the wallet loads **all** active data from the database: +1. **Accounts**: Iterates through all active BIP32 accounts in the `waddrmgr`. +2. **Horizons**: For each account, retrieves the current external and internal branch horizons (the index of the last used address). +3. **Historical Addresses**: Loads every address that has ever received funds. +4. **UTXOs**: Loads all unspent transaction outputs to detect spends. + +The `RecoveryState` is initialized with this data and immediately derives `N` new lookahead addresses (based on the `RecoveryWindow`) for every account branch. + +### 4.2 Loading for Targeted Rescan +When performing a targeted rescan (e.g., after `ImportAccount`), the caller provides specific targets. The wallet constructs a **Partial Recovery State**: +1. **Targets**: Only the specific accounts or addresses requested by the caller are loaded. +2. **Isolation**: Existing, fully-synced accounts are **excluded** from this state. +3. **Efficiency**: This ensures the scanner only spends CPU cycles matching the new keys, ignoring the thousands of keys that are already up-to-date. + +--- + +## 5. Full Block Scanning Algorithm + +This is the traditional scanning method, used when bandwidth is abundant or when privacy filters are not supported by the backend. + +### 5.1 The Algorithm +1. **Fetch Batch**: The Syncer requests a batch of full blocks (e.g., 20 blocks) directly from the backend (RPC `getblock` or P2P `MSG_BLOCK`). +2. **Process Sequentially**: It iterates through each block in memory. +3. **Transaction Matching**: + * **Inputs**: Checked against the `watchedOutPoints` map to detect spends. + * **Outputs**: Checked against the `addrFilters` map to detect receives. +4. **Horizon Expansion**: If a transaction pays to a lookahead address: + * Mark address as used. + * Derive new lookahead addresses. + * **No Restart Needed**: Since we have the full block data, we simply add the new addresses to the map and continue processing. Future blocks in the batch will be checked against the updated map. + +--- + +## 6. CFilter Scanning Algorithm + +This method uses BIP 157/158 Compact Filters to minimize bandwidth usage. It is complex because filters are probabilistic and abstract; we don't have the transaction data until we fetch the block. + +### 6.1 Optimistic Batch Processing with In-Place Resume +To overcome the latency of fetching headers -> filters -> blocks sequentially, we use an **Optimistic** strategy. + +1. **Parallel Fetch**: + * Assume the current lookahead window is sufficient. + * Fetch a large batch (e.g., 250 blocks) of **Headers** and **CFilters** in parallel. + +2. **Local Filtering**: + * Match the CFilters against the `RecoveryState`'s watchlist (Addresses + Outpoints). + * Queue only the *matching* blocks for download. + +3. **Sequential Process & Resume Loop**: + * Iterate through the batch. + * **Horizon Expansion Event**: If Block `N` contains a payment to a lookahead address, we must expand the window. + * **The Problem**: The filters for blocks `N+1` to `End` were checked against the *old* watchlist. They might contain payments to the *new* addresses we just derived. + * **The Fix (In-Place Resume)**: + * Pause processing. + * Update the watchlist with new addresses. + * **Re-Match** the filters for the remainder of the batch (`N+1`...`End`) against the new watchlist. + * Fetch any *newly* matched blocks. + * Resume processing from `N+1`. + +--- + +## 7. Strategy Comparison & Selection + +The wallet automatically selects the best strategy based on the environment and state. + +| Feature | Full Block Scanning | CFilter Scanning | +| :--- | :--- | :--- | +| **Bandwidth** | High (All data) | Low (Headers + Filters + Matched Blocks) | +| **CPU Usage** | Low (Hash map lookups) | High (Elliptic Curve ops + SIPHash matching) | +| **Latency** | Low (Local) / High (Remote) | Low (Parallel Fetch) | +| **Privacy** | High (Indistinguishable) | Medium (Leaks Block Interest) | +| **Best For** | Local Bitcoind, Huge Wallets | Mobile, Light Clients, Bandwidth Cap | + +### 7.1 Selection Logic (`SyncMethodAuto`) +The wallet uses a heuristic to choose: +1. **Backend Capability**: If the backend doesn't support BIP 157 (CFilters), fall back to Full Blocks. +2. **Watchlist Size**: If the wallet is watching > 100,000 items (addresses + UTXOs), CFilter matching becomes CPU-prohibitive. The wallet switches to **Full Block** scanning, as checking a map is O(1) regardless of size. +3. **Default**: Use **CFilters** for efficiency and privacy. + +--- + +## 8. Performance and Efficiency + +* **Write Batching**: Database writes are the single biggest bottleneck. The Syncer aggregates all findings (transactions, state updates) from a batch and commits them in a **single database transaction**. This reduces disk I/O by orders of magnitude compared to per-block commits. +* **Lookahead Derivation**: Address derivation is cached. The `RecoveryState` ensures we don't re-derive keys we've already generated, even if the scan is restarted. +* **Non-Blocking**: All scanning happens in a dedicated goroutine. The Wallet Controller remains responsive to `Info` and `Balance` requests even during a massive re-sync. \ No newline at end of file diff --git a/docs/developer/tx_invalidation_flows.md b/docs/developer/tx_invalidation_flows.md new file mode 100644 index 0000000000..c0efe9a015 --- /dev/null +++ b/docs/developer/tx_invalidation_flows.md @@ -0,0 +1,164 @@ +# Transaction Invalidation Flows + +This document defines how the SQL wallet store applies invalidation-related +write workflows when one wallet event changes tx history. It complements +[Wallet Data Model and Lifecycle](./utxo_data_model.md), which defines the +stored states, and +[ADR 0006: Wallet Transaction Manager SQL Schema](./adr/0006-wtxmgr-sql-schema.md), +which defines the schema and its invariants. + +It focuses on the workflow phases, the invariants each phase must preserve, +and the backend guarantees that follow from that model. + +## 1. Wallet Events and Scope + +From the wallet's point of view, a small set of events can change tx history: + +- tx ingest, e.g. when the wallet learns about a newly created or newly + confirmed tx. +- row-local metadata patching, e.g. when `UpdateTx` changes a label or + block/status fields without rewriting graph edges. +- invalidation of an unmined branch, e.g. when publisher-side cleanup fails + one local spend and its dependent descendants. +- rollback of confirmed history at a block boundary, e.g. when a reorg + disconnects blocks and rewinds formerly confirmed wallet history. + +The first two events place invalidation in the broader tx-store model. The +branch-mutating workflows described here are the ones that discover dependent +descendants, clear now-invalid spend edges, rewrite history, and commit the +whole result atomically. + +## 2. Terminology + +- **Root:** The first tx row directly affected by the wallet event. +- **Descendant:** An unmined tx that spends an output created by a root or by a + later discovered descendant. +- **Branch:** One root plus every descendant discovered from that root set. +- **Spend edge:** The wallet-owned relationship that records which tx spends a + previously created output. + +## 3. Core Invariants + +Every invalidation or rollback workflow must preserve the same wallet-visible +invariants. + +- **Atomicity:** Each wallet event executes in one database transaction. The + store must not commit a partially invalidated branch or a partially applied + rollback. +- **No dangling spend edges:** If one tx becomes invalid, the store must clear + any spend references that would otherwise keep invalid UTXO relationships + alive. +- **Retained invalid history:** Invalid, replaced, failed, or orphaned rows + remain part of the wallet's historical view. The workflow rewrites state; it + does not erase audit history. +- **Event-owned graph mutation:** Row-local patching must stay row-local. + Descendant traversal, spend-edge cleanup, replacement tracking, and rollback + orphaning belong only to the workflows that own those mutations. +- **Event-specific root states:** Different wallet events may drive the same + branch through different root-state outcomes. A direct conflict root may + become `replaced`, while a descendant invalidated by that same event becomes + `failed`. + +## 4. Workflow Phases + +When one branch-mutating event runs, the store follows the same overall phases. + +### 4.1 Discover roots + +The workflow first identifies the root rows directly affected by the wallet +event. Those roots may come from the tx being invalidated, from direct conflict +rows discovered during confirmed ingest, or from coinbase rows disconnected by +rollback. + +### 4.2 Snapshot candidate txns + +Before mutation starts, the workflow loads the current unmined tx set needed +for descendant discovery. This snapshot must happen first so later state +rewrites do not hide part of the branch that still needs cleanup. + +### 4.3 Discover descendants + +The workflow then walks the unmined candidate set to a fixed point. Each newly +discovered descendant expands the invalid parent set, which may reveal later +txns farther down the branch. + +### 4.4 Clear spend edges + +Before any affected row becomes visibly invalid, the workflow clears the +wallet-owned spend edges claimed by that root or descendant set. This prevents +the store from exposing rewritten rows that still appear to spend outputs from +an invalid branch. + +### 4.5 Rewrite roots + +After the root set is fully known and its spend edges are safe to rewrite, the +workflow applies the event-specific root outcome. The root state depends on the +wallet event, not just on the graph shape. + +### 4.6 Rewrite descendants + +Once the root outcome is fixed, the workflow rewrites dependent descendants to +their derived invalid state. Direct roots and descendants do not always land in +the same state, so they are handled as separate phases. + +### 4.7 Commit atomically + +Either every phase above commits together or none of them does. The workflow +must not leave behind half-rewritten states, partially cleared spend edges, or +only part of the affected branch updated. + +## 5. Event Outcomes + +The workflow phases stay the same across events, but the resulting root state +depends on which wallet event started the flow. + +| Wallet event | Root outcome | Descendant outcome | Example | +| --- | --- | --- | --- | +| `InvalidateUnminedTx` | `failed` | `failed` | Publisher-side cleanup rejects one local unmined branch. | +| `CreateTx` conflict handling | direct conflict roots become `replaced` | dependent descendants become `failed` | A newly confirmed winner claims wallet-owned inputs already spent by an unmined branch. | +| `RollbackToBlock` | disconnected coinbase roots become `orphaned` | dependent descendants become `failed` | A reorg disconnects the confirming block for the root branch. | + +Row-local metadata patching does not enter this branch workflow because it does +not discover descendants or rewrite spend edges. + +## 6. Worked Example + +Consider one branch with three txns: + +- `A` is the root tx. +- `B` spends an output created by `A`. +- `C` spends an output created by `B`. + +The same branch can be rewritten differently depending on the initiating event: + +- Under `InvalidateUnminedTx(A)`, `A`, `B`, and `C` all become `failed`. +- Under confirmed conflict handling against `A`, `A` becomes `replaced` while + `B` and `C` become `failed`. +- Under rollback that disconnects confirmed coinbase `A`, `A` becomes + `orphaned` while `B` and `C` become `failed`. + +In every case, descendant discovery happens before mutation and spend-edge +cleanup happens before the rewritten states become visible. + +## 7. Backend Guarantees + +Postgres and sqlite may differ internally in query bindings, row types, and +helper structure. They must still preserve the same workflow guarantees. + +- The same wallet event yields the same final tx states. +- The same descendant branch is invalidated for the same root set. +- Spend-edge cleanup happens before invalid state becomes visible. +- The whole event remains all-or-nothing at the SQL transaction boundary. + +These guarantees keep the SQL stores aligned with the legacy `kvdb` backend at +the event level, even when the SQL stores retain richer explicit invalid- +history states internally. + +## 8. Relationship to Other Docs + +- [`wallet/internal/db/interface.go`](../../wallet/internal/db/interface.go) + describes the caller-facing `TxStore` contract. +- [Wallet Data Model and Lifecycle](./utxo_data_model.md) explains the + persisted states and the "retain history" policy. +- [ADR 0006: Wallet Transaction Manager SQL Schema](./adr/0006-wtxmgr-sql-schema.md) + defines the schema-level invariants that these workflows must preserve. diff --git a/docs/developer/unit_testing_guidelines.md b/docs/developer/unit_testing_guidelines.md index 89db1ffd5d..088991f1c9 100644 --- a/docs/developer/unit_testing_guidelines.md +++ b/docs/developer/unit_testing_guidelines.md @@ -239,6 +239,69 @@ func TestProcessPaymentHandlesOrchestration(t *testing.T) { } ``` +## 6. Prefer `mock.Mock` for Interface Mocks + +When a test needs to implement an interface for expectations or call +verification, prefer embedding `mock.Mock` from `github.com/stretchr/testify/mock` +over writing a bespoke stub struct. This preference applies specifically when you +need to verify that methods were called with certain arguments or a specific +number of times. For simple stubs that only return static values without +tracking calls, a basic struct or function literal may still be appropriate. + +Using `mock.Mock` provides several advantages: +- **Explicit Expectations:** Define exactly which methods must be called, and + with what arguments, using `On(...).Return(...)`. +- **Call Verification:** Use `AssertExpectations(t)` to ensure all expected + calls were made. This is cleaner than manually tracking call counts in a stub. +- **Consistent API:** Aligns with existing project tests, making mocks easier + to read and maintain. + +### Example: Using `mock.Mock` + +First, define the mock struct by embedding the concrete `mock.Mock` type. In +the method implementation, use `m.Called` to record the call and return the +arguments. Use a defensive type assertion to safely handle unexpected return +types: + +```go +type mockStore struct { + mock.Mock +} + +func (m *mockStore) TxDetails(ctx context.Context, hash *chainhash.Hash) (*wtxmgr.TxDetails, error) { + args := m.Called(ctx, hash) + + // Safely handle the first return value with a type assertion. + details, ok := args.Get(0).(*wtxmgr.TxDetails) + if !ok && args.Get(0) != nil { + // Return an error if the mock was misconfigured with the wrong type. + return nil, fmt.Errorf("mock type error: TxDetails result") + } + + return details, args.Error(1) +} +``` + +Then, use the mock in your test by setting expectations and asserting them at +the end of the "Assert" block. + +```go +func TestGetTx(t *testing.T) { + // Arrange + store := &mockStore{} + store.On("TxDetails", mock.Anything, TstTxHash). + Return(&wtxmgr.TxDetails{...}, nil).Once() + + // Act + tx, err := GetTx(store, TstTxHash) + + // Assert + require.NoError(t, err) + require.NotNil(t, tx) + store.AssertExpectations(t) +} +``` + ## 7. Prefer `require` Over `assert` The `testify` library provides both `require` and `assert` packages. diff --git a/docs/developer/utxo_data_model.md b/docs/developer/utxo_data_model.md new file mode 100644 index 0000000000..19c43d90fa --- /dev/null +++ b/docs/developer/utxo_data_model.md @@ -0,0 +1,316 @@ +# Wallet Data Model and Lifecycle + +This document defines the core data model, state machines, and design philosophy of the `btcwallet` transaction subsystem. It serves as the definitive reference for understanding how the wallet manages money, history, and state. + +For the exact write-path rules that apply transaction ingress, invalidation, and rollback to the stored graph, see [Transaction Invalidation Flows](./tx_invalidation_flows.md). + +## 1. Design Philosophy + +The `btcwallet` architecture is built upon a specific worldview: **The wallet is a UTXO Manager.** + +While Bitcoin users often think in terms of "Balances" and "Transactions," the operational reality of the Bitcoin protocol is the **Unspent Transaction Output (UTXO)**. A wallet's primary responsibility is to discover, secure, and select these outputs for spending. + +### 1.1 Structural vs. Operational Centrality + +Our data model distinguishes between data integrity and operational efficiency: + +* **Structurally Transaction-Centered:** Data integrity flows from the parent Transaction to the child UTXO. A UTXO cannot exist without a creating transaction. If a transaction is invalid (double-spent), its outputs are invalid. +* **Operationally UTXO-Centered:** The read path is optimized for UTXOs. 99% of wallet operations (calculating balance, selecting coins for a new payment) query the **UTXO set**, not the transaction history. + +### 1.2 The "Immutable History" Policy + +We adhere to a strict **"Never Delete History"** policy. +* **Chain Data is Immutable:** Once a transaction is mined, it happened. We record it. +* **Intent is Immutable:** If a user attempts a transaction that later fails (e.g., RBF replaced), that attempt is part of the wallet's audit trail. +* **Soft Deletion:** We do not physically `DELETE` rows. Invalid or replaced transactions are marked with a status (`failed`, `replaced`) so they are ignored by balance calculations but preserved for history. + +--- + +## 2. The Data Model + +The wallet state is composed of four primary entities. + +```mermaid +classDiagram + direction LR + + class Block { + Height + Hash + Timestamp + } + + class Transaction { + TxHash + RawBytes + Status + BlockHeight (FK) + } + + class UTXO { + OutPoint (TxHash:Index) + Amount + Address + SpentBy (FK) + } + + class Lease { + LockID + Expiration + } + + Block "1" --> "0..*" Transaction : Confirms + Transaction "1" --> "0..*" UTXO : Creates + Transaction "0..1" --> "0..*" UTXO : Spends (Inputs) + UTXO "1" --> "0..1" Lease : Locks +``` + +Note: This diagram is conceptual and uses natural identifiers like `TxHash` and an outpoint (`TxHash:Index`). The SQL schema uses surrogate keys (for example `transactions.id`) and scopes all `wtxmgr` rows by `wallet_id`. + +Mapping (conceptual -> SQL): + +| Concept | SQL (example) | +| --- | --- | +| `TxHash` | `transactions.tx_hash` (unique per `wallet_id`) | +| Transaction row ID | `(transactions.wallet_id, transactions.id)` | +| OutPoint (`TxHash:Index`) | `(utxos.wallet_id, utxos.tx_id, utxos.output_index)` | +| Address (address book) | `addresses.id` (address-manager schema) | +| Confirmation | `transactions.block_height` (FK to `blocks(block_height)`) | +| Lease | `(utxo_leases.wallet_id, utxo_leases.utxo_id)` | + +### 2.1 Entity Roles + +* **Block:** The anchor for time and confirmation. It handles reorgs. If a block is disconnected, linked transactions automatically lose their confirmation status. +* **Transaction:** The provenance record. It stores the *intent* (Who sent? When?) and the *status* (Confirmed? Failed?). +* **UTXO:** The unit of value. This is the "Coin". It is the bridge between transactions. +* **Lease:** A temporary, memory-like lock on a UTXO to prevent double-spending within the wallet application (e.g., reserving a coin for a Lightning channel open). + +--- + +## 3. Transaction Lifecycle + +A Transaction represents an event that *moves* value. Its lifecycle tracks the journey from user intent to blockchain finality. + +### 3.1 State Machine + +```mermaid +stateDiagram-v2 + [*] --> Pending : User Creates + [*] --> Published : P2P/Mempool + + Pending --> Published : Broadcast + + %% Confirmation is orthogonal (BlockHeight != NULL) + + Published --> Replaced : RBF (Higher Fee Tx Wins) + Published --> Failed : Double-Spend (Other Tx Wins) + Published --> Orphaned : Reorged Coinbase + + Pending --> Failed : Inputs Spent Elsewhere + + %% Coinbase can re-enter the best chain after a reorg. + Orphaned --> Published : Reconfirmed + + Replaced --> [*] + Failed --> [*] + Orphaned --> [*] +``` + +### 3.2 Status vs. Confirmation + +We distinguish between a transaction's **Validity** (Status) and its **Confirmation** (Block Height). + +**1. Confirmation (Source of Truth: `BlockHeight`)** +* **Unconfirmed:** `BlockHeight IS NULL` +* **Confirmed:** `BlockHeight IS NOT NULL` (points to a valid block) + +**2. Validity (Source of Truth: `Status`)** + +| Status Code | Meaning | Affects Balance? | +| :--- | :--- | :--- | +| `0` (`pending`) | Created locally, not yet broadcast. | **Yes** (Factual) | +| `1` (`published`) | Active in mempool or blockchain. | **Yes** (If valid) | +| `2` (`replaced`) | Replaced by a higher-fee transaction (RBF). | **No** | +| `3` (`failed`) | Invalidated by a conflicting transaction (Double-Spend). | **No** | +| `4` (`orphaned`) | Coinbase tx that was reorged out (Invalid). | **No** | + +*Additional invariant: Coinbase transactions cannot exist in the mempool. A coinbase transaction is either confirmed (`BlockHeight IS NOT NULL` and `Status = 1`, `published`) or orphaned (`Status = 4`, `orphaned`, and `BlockHeight IS NULL`).* + +Factual-balance note: the SQL Store layer treats both `pending` and +`published` parent transactions as part of the current live UTXO set. More +conservative spendability policy (for example, excluding `pending` parents) is +applied by higher-level callers on top of that factual base. + +*Note: There is no "Abandoned" state. A broadcast transaction cannot be safely abandoned; it can only be invalidated by double-spending its inputs.* + +--- + +## 4. UTXO Lifecycle + +A UTXO represents **spendable potential**. Its state is derived entirely from its parent Transaction and its "Spent By" pointer. + +### 4.1 State Machine + +```mermaid +stateDiagram-v2 + state "Spendable (Unspent)" as Unspent + state "Immature (Coinbase)" as Immature + state "Leased (Locked)" as Leased + state "Spent (Archive)" as Spent + state "Dead (Invalid)" as Dead + + [*] --> Unspent : Created by Valid Tx + [*] --> Immature : Coinbase Confirmed + + Unspent --> Leased : Application Lock + Leased --> Unspent : Lock Expire/Release + + Unspent --> Spent : Used in New Tx + Leased --> Spent : Forced Spend + + Spent --> Unspent : Reorg (Spending Tx Invalidated) + + %% Coinbase maturity + Immature --> Unspent : Matures (100 blocks) + + %% Parent validity changes (derived, not persisted) + Unspent --> Dead : Parent Invalidated + Immature --> Dead : Parent Orphaned + Dead --> Immature : Parent Reconfirmed (Coinbase) +``` + +### 4.2 Derivation Logic + +A UTXO does not store its own status field. Its status is calculated dynamically to ensure consistency. + +| UTXO State | Parent Tx Status | `BlockHeight` | `SpentBy` Pointer | Meaning | +| :--- | :--- | :--- | :--- | :--- | +| **Confirmed** | `1` (`published`) | `NOT NULL` | `NULL` | Available, mature funds. | +| **Unconfirmed** | `1` (`published`) | `NULL` | `NULL` | Incoming funds, risk of RBF. | +| **Immature** | `1` (`published`) coinbase | `NOT NULL` | `NULL` | Mined coins, must wait 100 blocks. | +| **Spent** | `1` (`published`) | `NOT NULL` | `NOT NULL` | History. We used this money. | +| **Dead (Permanent)** | `3` / `2` (`failed` / `replaced`) | *Any* | *Any* | Permanently invalid output from a failed attempt. | +| **Dead (Recoverable)** | `4` (`orphaned`) coinbase | `NULL` | *Any* | Orphaned coinbase output. Can become `Immature` if parent is reconfirmed. | + +--- + +## 5. Operational Behaviors + +### 5.1 Reorg Handling (Auto-Healing) + +```mermaid +graph TD + Block[Block N - Orphaned] -.->|Invalidates| Tx[Transaction] + + Tx -->|State becomes Unconfirmed| Output[Created UTXO] + Output -.->|State becomes Unconfirmed| Balance[Wallet Balance] + + Tx -->|Releases| Input[Spent UTXO] + Input -.->|State becomes Unspent| Balance +``` + +When the blockchain reorganizes (disconnects blocks): +1. **Block** record is disconnected from the best chain (deleted/replaced). +2. **Transactions** in that block have their `BlockHeight` set to `NULL`. +3. **Effect (non-coinbase):** Regular transactions revert to `Unconfirmed` state (but remain `published`). + * **Coinbase special case:** Coinbase transactions from the disconnected block are marked `orphaned` (as per ADR 0006) instead of reverting to `Unconfirmed`, since they cannot exist outside of the block that created them. +4. **Cascading Effect:** + * Outputs created by these txs become `Unconfirmed` (except for outputs of `orphaned` coinbase txs, which are no longer considered part of the spendable UTXO set). + * Inputs spent by these txs revert to `Unspent` (if the spending tx is completely invalidated). + +Implementation note: The SQL `blocks` table models the current best chain. During a disconnect, the best-chain association is removed (for example by deleting the `blocks` row at that height and inserting the new one). The coinbase status update MUST be atomic with clearing `BlockHeight` to avoid an invalid "unconfirmed coinbase" state. + +Coinbase reconfirmation note: If an orphaned coinbase transaction re-enters the best chain, restoring it requires updating `BlockHeight` and `Status = 1` (`published`) atomically (one SQL statement within a transaction) to satisfy coinbase invariants. + +Reconfirmation semantics: +* The transaction keeps the same `tx_hash` and is associated with a new `block_height`. +* Outputs created by the transaction transition from `Dead (Recoverable)` back to `Immature` (and later become spendable after maturity). + +Deep reorg note: A deep reorg is handled as a batch of block disconnects. Implementations should treat this as a bounded, transactional operation (potentially chunked) rather than assuming a single-block example. + +### 5.2 RBF (Replace-By-Fee) + +```mermaid +graph TD + Input[Input UTXO] + + Input -->|Spent By| TxB[Tx B - New] + TxB -->|Status: Published| Network + + Input -.->|Was Spent By| TxA[Tx A - Old] + TxA -->|Status: Replaced| Archive +``` + +When the wallet detects a replacement transaction (Tx B) for an existing unconfirmed transaction (Tx A): +1. **Detection:** Tx B spends the same inputs as Tx A. +2. **Update:** The input UTXOs are updated to point to Tx B (`SpentBy = TxB`). +3. **Archive:** Tx A is marked as `status = 2` (`replaced`). It remains in the DB for history but is ignored by balance queries. + +### 5.3 Upstream Double-Spend (Invalidation) + +```mermaid +graph TD + Input[Input UTXO] + + Input -->|Spent By| TxD[Tx D - Competitor] + TxD -->|Status: Confirmed| Blockchain + + Input -.->|Was Spent By| TxC[Tx C - Ours] + TxC -->|Status: Failed| Archive + + TxC -->|Creates| Output[Output UTXO] + Output -.->|Status: Dead| Archive +``` + +When a transaction (Tx C) becomes invalid because *its input* was spent by a conflicting transaction (Tx D) that confirmed: +1. **Detection:** We see Tx D confirmed in a block. It spends UTXO X. +2. **Conflict:** We have Tx C (unconfirmed) which also spends UTXO X. +3. **Resolution:** + * Update UTXO X: `SpentBy = TxD`. + * Mark Tx C: `Status = 3` (`failed`). + * **Cascade:** Any UTXOs created by Tx C are now effectively "Dead" (because their parent is `failed`). + * Any transactions spending those dead UTXOs must also be marked `failed` (recursive invalidation). + +Architectural consequence: Recursive invalidation can require graph traversal across transaction chains. Typical wallet histories are shallow, but the design should acknowledge worst-case depth and define an execution strategy (batch processing, recursion limits, and/or iterative traversal) to avoid pathological performance. + +Suggested implementation strategy: +1. Start with a queue of newly-invalid transactions (double-spent, replaced, or orphaned). +2. Mark those transactions `failed`/`replaced`/`orphaned`. +3. Find UTXOs created by those transactions. +4. For each such UTXO, find transactions that spend it and enqueue them. +5. Repeat until the queue is empty. + +This can be implemented with an application-side loop (portable across databases) or a database-side recursive CTE (PostgreSQL). + +### 5.4 Coin Selection +Coin selection queries focus purely on the UTXO set: +1. Filter by `SpentBy IS NULL`. +2. Join the parent transaction and choose a caller-specific live-status + filter. + * Conservative callers can require `Status = 1` (`published`). + * Zero-latency chaining can opt into `Status IN (0, 1)` (`pending`, + `published`), but this creates a strict dependency: the parent must be + broadcast before the child. + * If confirmation is required (e.g., for safety), add + `AND BlockHeight IS NOT NULL`. +3. Filter out active `Leases`. + +Important: There is no dedicated `spendable_utxos` view in the current branch. +Coin selection should build directly on factual UTXO queries and then apply its +own policy filters for pending parents, active leases, and immature coinbase +outputs. Active leases should be excluded using the caller's UTC `now` value, +not an implicit database-local timestamp. + +Transaction reconstruction note: This model optimizes the high-frequency UTXO read path. Reconstructing a full transaction view (inputs/outputs for UI/history) is inherently more join-heavy and should be treated as a lower-frequency, separately optimized read path. + +### 5.5 Multi-Wallet Concurrency & Consistency +When multiple wallets share a single database, the transaction graph remains +wallet-scoped through `transactions.wallet_id`, and other rows either carry +`wallet_id` directly or derive wallet ownership through that transaction graph. +This avoids conflicts and makes correctness properties explicit. + +* **Consistency model:** The system assumes ACID semantics from the database. Reorg handling, status transitions, and lease acquisition must be performed as explicit SQL transactions. +* **Concurrency primitive:** UTXO leases are the wallet-level lock. Coin selection must exclude leased UTXOs, and lease acquisition should be treated as part of the same atomic unit as input selection. +* **Coinbase maturity:** Coinbase UTXOs require 100 confirmations. This rule should be enforced in a single, well-defined caller-side policy path to avoid accidental selection of immature funds. + +Suggested enforcement: a parameterized caller-side query helper or composition layer that takes `current_height` and filters out coinbase UTXOs where `current_height - block_height < 100`. diff --git a/docs/user/README.md b/docs/user/README.md new file mode 100644 index 0000000000..906a2cf6ee --- /dev/null +++ b/docs/user/README.md @@ -0,0 +1,19 @@ +# User Documentation + +This section contains documentation for end-users and operators of `btcwallet`. + +--- + +## 🔄 Synchronization Modes + +A detailed guide on choosing between Compact Filters (`SyncMethodCFilters`) and Full Blocks (`SyncMethodFullBlocks`) based on your transaction density. Includes performance benchmarks and strategy recommendations. + +**[➡️ Read the Synchronization Modes Guide](./synchronization_modes.md)** + +--- + +## 🛠️ Rebuilding Transaction History + +Instructions on how to force a full wallet rescan using the `dropwtxmgr` tool. Useful for recovering from database corruption or missing history. + +**[➡️ Learn about Forced Rescans](./force_rescans.md)** diff --git a/docs/user/synchronization_modes.md b/docs/user/synchronization_modes.md new file mode 100644 index 0000000000..351779a53c --- /dev/null +++ b/docs/user/synchronization_modes.md @@ -0,0 +1,65 @@ +# Synchronization Modes: Compact Filters vs. Full Blocks + +When connecting to a chain backend, `btcwallet` offers two distinct synchronization strategies, configured via the `SyncMethod` parameter: + +1. **`SyncMethodCFilters`**: Uses lightweight Compact Filters (Neutrino) to scan for relevant transactions. +2. **`SyncMethodFullBlocks`**: Downloads full blocks (or batches of blocks) to scan locally. + +Choosing the right mode can significantly impact your wallet's startup time, bandwidth usage, and CPU load. This guide explains the differences and provides performance benchmarks to help you decide which mode fits your use case. + +## Summary Recommendation + +| Transaction Density | Recommended Mode | Why? | +| :--- | :--- | :--- | +| **Low Frequency** (< 1 hit per 100 blocks) | **`SyncMethodCFilters`** | **~4x Faster.** Optimized for sparse histories by avoiding block data entirely. | +| **Moderate Frequency** (1 hit per 10-100 blocks) | **`SyncMethodCFilters`** | **~2x Faster.** Still faster than full blocks due to reduced data transfer. | +| **High Frequency** (> 1 hit per 10 blocks) | **`SyncMethodFullBlocks`** | **High Throughput.** Most efficient when hits are dense, avoiding matching overhead. | + +--- + +## 1. Compact Filters (CFilter) +**Default & Recommended for 99% of Users.** + +In this mode (`SyncMethodCFilters`), the wallet downloads lightweight **Neutrino Compact Filters** (Golomb-Rice filters) for each block to check if the block contains any relevant transactions. +* **Process**: Fetch Filter -> Match Filter -> (Only if Match) Fetch Block. +* **Pros**: + * Extremely fast for "empty" blocks. + * Minimal bandwidth usage (filters are ~15KB vs blocks ~1-4MB). +* **Cons**: + * Slower if every block is a "hit" (requires double round-trips: get filter, then get block). + +## 2. Full Blocks +**Recommended for High-Density Wallets.** + +In this mode (`SyncMethodFullBlocks`), the wallet indiscriminately downloads every full block (or batches of blocks) and scans them locally. +* **Process**: Fetch Batch of Blocks -> Scan All. +* **Pros**: + * Linear scaling for high-traffic wallets. + * Eliminates the "Match Filter -> Fetch Block" latency penalty when hits are frequent. +* **Cons**: + * High bandwidth (downloads the entire blockchain history during rescan). + * High CPU/Memory usage (parsing gigabytes of JSON/Hex block data). + +--- + +## Performance Analysis & UTXO Density + +We benchmarked both modes against a standard `bitcoind` node over a range of 1,000 blocks. The performance depends heavily on **UTXO Density**: how often your wallet sends or receives a transaction relative to the number of blocks. + +### Benchmark Results (1,000 Blocks) + +| Wallet Activity (Density) | Real-World Equivalent | `SyncMethodFullBlocks` | `SyncMethodCFilters` | Winner | +| :--- | :--- | :--- | :--- | :--- | +| **0.001 (0.1%)** | **1 Tx / Week** | ~1.9x Faster | **~3.8x Faster** | **`SyncMethodCFilters`** | +| **0.01 (1.0%)** | **1 Tx / 16 Hours** | ~1.5x Faster | **~2.1x Faster** | **`SyncMethodCFilters`** | +| **0.1 (10%)** | **10 Txs / Hour** | **~1.8x Faster** | Slower | **`SyncMethodFullBlocks`** | + +* *Speedups are compared against the legacy synchronization API.* + +### Interpreting Density +* **Low to Moderate Frequency (Density < 0.1)**: This represents the vast majority of wallet usage. If your wallet receives or sends funds a few times a day or less, your transaction history is "sparse" relative to the blockchain (~144 blocks/day). **Use `SyncMethodCFilters`** to save bandwidth and reduce sync time. +* **High Frequency (Density > 0.1)**: This represents "dense" wallets that are active in **1 out of every 10 blocks** (or more). For these high-traffic scenarios, the overhead of double-fetching (filter match then block fetch) exceeds the cost of just fetching everything. **Use `SyncMethodFullBlocks`** for maximum throughput. + +## Conclusion + +The new synchronization architecture in `btcwallet` is designed to be adaptive. By default, **`SyncMethodCFilters`** provides a superior experience for typical users, offering massive speedups and bandwidth savings. However, for high-workload scenarios where the wallet effectively indexes a significant portion of the chain, **`SyncMethodFullBlocks`** mode provides a robust, high-throughput alternative that outperforms the legacy implementation. diff --git a/go.mod b/go.mod index 370f6965da..9b10eed62f 100644 --- a/go.mod +++ b/go.mod @@ -13,48 +13,113 @@ require ( github.com/btcsuite/btcwallet/walletdb v1.5.1 github.com/btcsuite/btcwallet/wtxmgr v1.5.6 github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 - github.com/davecgh/go-spew v1.1.1 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 + github.com/docker/go-connections v0.6.0 + github.com/golang-migrate/migrate/v4 v4.19.1 github.com/golang/protobuf v1.5.4 + github.com/jackc/pgx/v5 v5.5.4 github.com/jessevdk/go-flags v1.6.1 github.com/jrick/logrotate v1.1.2 + github.com/lib/pq v1.10.9 github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf github.com/lightninglabs/neutrino v0.16.2 github.com/lightninglabs/neutrino/cache v1.1.3 github.com/lightningnetwork/lnd/fn/v2 v2.0.8 github.com/lightningnetwork/lnd/ticker v1.1.1 github.com/lightningnetwork/lnd/tlv v1.3.2 - github.com/stretchr/testify v1.10.0 - golang.org/x/crypto v0.41.0 - golang.org/x/net v0.43.0 - golang.org/x/sync v0.16.0 - golang.org/x/term v0.34.0 - google.golang.org/grpc v1.73.0 - google.golang.org/protobuf v1.36.6 + github.com/stretchr/testify v1.11.1 + github.com/testcontainers/testcontainers-go v0.40.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 + golang.org/x/crypto v0.45.0 + golang.org/x/net v0.47.0 + golang.org/x/sync v0.18.0 + golang.org/x/term v0.37.0 + google.golang.org/grpc v1.75.1 + google.golang.org/protobuf v1.36.10 + modernc.org/sqlite v1.40.1 ) require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/aead/siphash v1.0.1 // indirect github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect github.com/decred/dcrd/lru v1.1.2 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v28.5.1+incompatible // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/ebitengine/purego v0.8.4 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/kkdai/bstream v1.0.0 // indirect - github.com/kr/pretty v0.3.1 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/lightningnetwork/lnd/clock v1.0.1 // indirect github.com/lightningnetwork/lnd/queue v1.0.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.1.0 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/shirou/gopsutil/v4 v4.25.6 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/stretchr/objx v0.5.2 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect go.etcd.io/bbolt v1.3.11 // indirect - go.opentelemetry.io/otel v1.36.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.37.0 // indirect + go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect golang.org/x/exp v0.0.0-20250811191247-51f88131bc50 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.28.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.66.10 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) // If you change this please run `make lint` to see where else it needs to be // updated as well. go 1.24.6 + +// We use a replace directive here for our internal wtxmgr module. This ensures +// that we can freely move between tagged releases and development commits of +// this module without needing to constantly update the go.mod file. +replace github.com/btcsuite/btcwallet/wtxmgr => ./wtxmgr + +replace github.com/btcsuite/btcwallet/wallet/txsizes => ./wallet/txsizes diff --git a/go.sum b/go.sum index 02645e0d08..1b51fc4723 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,11 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/aead/siphash v1.0.1 h1:FwHfE/T45KPKYuuSAKyyvE+oPWcaQ+CUmFW0bPlM+kg= github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= @@ -29,12 +37,8 @@ github.com/btcsuite/btcwallet/wallet/txauthor v1.3.5 h1:Rr0njWI3r341nhSPesKQ2JF+ github.com/btcsuite/btcwallet/wallet/txauthor v1.3.5/go.mod h1:+tXJ3Ym0nlQc/iHSwW1qzjmPs3ev+UVWMbGgfV1OZqU= github.com/btcsuite/btcwallet/wallet/txrules v1.2.2 h1:YEO+Lx1ZJJAtdRrjuhXjWrYsmAk26wLTlNzxt2q0lhk= github.com/btcsuite/btcwallet/wallet/txrules v1.2.2/go.mod h1:4v+grppsDpVn91SJv+mZT7B8hEV4nSmpREM4I8Uohws= -github.com/btcsuite/btcwallet/wallet/txsizes v1.2.5 h1:93o5Xz9dYepBP4RMFUc9RGIFXwqP2volSWRkYJFrNtI= -github.com/btcsuite/btcwallet/wallet/txsizes v1.2.5/go.mod h1:lQ+e9HxZ85QP7r3kdxItkiMSloSLg1PEGis5o5CXUQw= github.com/btcsuite/btcwallet/walletdb v1.5.1 h1:HgMhDNCrtEFPC+8q0ei5DQ5U9Tl4RCspA22DEKXlopI= github.com/btcsuite/btcwallet/walletdb v1.5.1/go.mod h1:jk/hvpLFINF0C1kfTn0bfx2GbnFT+Nvnj6eblZALfjs= -github.com/btcsuite/btcwallet/wtxmgr v1.5.6 h1:Zwvr/rrJYdOLqdBCSr4eICEstnEA+NBUvjIWLkrXaYI= -github.com/btcsuite/btcwallet/wtxmgr v1.5.6/go.mod h1:lzVbDkk/jRao2ib5kge46aLZW1yFc8RFNycdYpnsmZA= github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd h1:R/opQEbFEy9JGkIguV40SvRY1uliPX8ifOvi6ICsFCw= github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= @@ -44,11 +48,25 @@ github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 h1:R8vQdOQdZ9Y3SkEwmHoWBmX1DNXhXZqlTpq6s4tyJGc= github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= github.com/decred/dcrd/crypto/blake256 v1.1.0 h1:zPMNGQCm0g4QTY27fOCorQW7EryeQ/U0x++OzVrdms8= github.com/decred/dcrd/crypto/blake256 v1.1.0/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= @@ -58,12 +76,33 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjY github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= github.com/decred/dcrd/lru v1.1.2 h1:KdCzlkxppuoIDGEvCGah1fZRicrDH36IipvlB1ROkFY= github.com/decred/dcrd/lru v1.1.2/go.mod h1:gEdCVgXs1/YoBvFWt7Scgknbhwik3FgVSzlnCcXL2N8= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM= +github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= +github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= +github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= @@ -78,13 +117,26 @@ github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.4 h1:Xp2aQS8uXButQdnCMWNmvx6UysWQQC+u1EoizjguY+8= +github.com/jackc/pgx/v5 v5.5.4/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= @@ -95,10 +147,14 @@ github.com/jrick/logrotate v1.1.2/go.mod h1:f9tdWggSVK3iqavGpyvegq5IhNois7KXmasU github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= github.com/kkdai/bstream v1.0.0 h1:Se5gHwgp2VT2uHfDrkbbgbgEvV9cimLELwrPJctSjg8= github.com/kkdai/bstream v1.0.0/go.mod h1:FDnDOHt5Yx4p3FaHcioFT0QjDOtgUpvjeZqAs+NVZZA= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf h1:HZKvJUHlcXI/f/O0Avg7t8sqkPo78HFzjmeYFl6DPnc= github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf/go.mod h1:vxmQPeIQxPf6Jf9rM8R+B4rKBqLA2AjttNxkFBL2Plk= github.com/lightninglabs/neutrino v0.16.2 h1:jHMMDLPX8asfwgN0/C4BY8uVaYupFzZYuWQkX8Go3fk= @@ -116,6 +172,34 @@ github.com/lightningnetwork/lnd/ticker v1.1.1 h1:J/b6N2hibFtC7JLV77ULQp++QLtCwT6 github.com/lightningnetwork/lnd/ticker v1.1.1/go.mod h1:waPTRAAcwtu7Ji3+3k+u/xH5GHovTsCoSVpho0KDvdA= github.com/lightningnetwork/lnd/tlv v1.3.2 h1:MO4FCk7F4k5xPMqVZF6Nb/kOpxlwPrUQpYjmyKny5s0= github.com/lightningnetwork/lnd/tlv v1.3.2/go.mod h1:pJuiBj1ecr1WWLOtcZ+2+hu9Ey25aJWFIsjmAoPPnmc= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= +github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= @@ -125,89 +209,139 @@ github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5 github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= +github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= +github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= +github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk= +github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0/go.mod h1:h+u/2KoREGTnTl9UwrQ/g+XhasAT8E6dClclAADeXoQ= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0= go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= -go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= -go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= -go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= -go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= -go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= -go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= -go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= -go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= -go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 h1:dIIDULZJpgdiHz5tXrTgKIMLkus6jEFa7x5SOKcyR7E= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0/go.mod h1:jlRVBe7+Z1wyxFSUs48L6OBQZ5JwH2Hg/Vbl+t9rAgI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= +go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= +go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= -golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/exp v0.0.0-20250811191247-51f88131bc50 h1:3yiSh9fhy5/RhCSntf4Sy0Tnx50DmMpQ4MQdKKk4yg4= golang.org/x/exp v0.0.0-20250811191247-51f88131bc50/go.mod h1:rT6SFzZ7oxADUDx58pcaKFTcZ+inxAa9fTrYx/uVYwg= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= -golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= -google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= +google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 h1:8XJ4pajGwOlasW+L13MnEGA8W4115jJySQtVfS2/IBU= +google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4/go.mod h1:NnuHhy+bxcg30o7FnVAZbXsPHUDQ9qKWAQKCD7VxFtk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 h1:i8QOKZfYg6AbGVZzUAY3LrNWCKF8O6zFisU9Wl9RER4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= +google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= +google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -219,5 +353,33 @@ gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= +modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= +modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= +modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY= +modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/itest/README.md b/itest/README.md new file mode 100644 index 0000000000..1c1e9e4adc --- /dev/null +++ b/itest/README.md @@ -0,0 +1,57 @@ +# itest + +`itest` contains end-to-end integration tests for `btcwallet` using the harness +in `bwtest`. + +## Running Tests + +Common invocations: + +```bash +make itest + +# Select a chain backend. +make itest chain=btcd +make itest chain=bitcoind + +# Select a wallet database backend. +make itest db=kvdb + +# Filter cases by regex. +make itest icase=manager +``` + +The `chain` and `db` variables are forwarded into the test binary as flags. + +## Test Case Naming + +Integration test case names must follow: + +``` +component action +``` + +For example: + +``` +manager create wallet +``` + +This is validated by `itest/main_test.go`. + +## Logs + +Each test run creates a per-run log directory under: + +`itest/test-logs/log---YYYYMMDD-HHMMSS/` + +The harness flattens backend logs into: + +- `miner.log` +- `chain_backend.log` + +Wallet logs are created per test case: + +- `wallet-.log` + +The log directory path is printed when it is created. diff --git a/itest/list_on_test.go b/itest/list_on_test.go new file mode 100644 index 0000000000..9b5e0e2cf6 --- /dev/null +++ b/itest/list_on_test.go @@ -0,0 +1,22 @@ +//go:build itest + +package itest + +import "github.com/btcsuite/btcwallet/bwtest" + +// testCase defines a single integration test case. +type testCase struct { + // Name is the human-readable name of the test case. + Name string + + // TestFunc executes the test case. + TestFunc func(t *bwtest.HarnessTest) +} + +// allTestCases is the full set of integration test cases. +var allTestCases = []*testCase{ + { + Name: "manager create wallet", + TestFunc: testCreateWallet, + }, +} diff --git a/itest/main_test.go b/itest/main_test.go new file mode 100644 index 0000000000..3ef90ad2bb --- /dev/null +++ b/itest/main_test.go @@ -0,0 +1,124 @@ +//go:build itest + +package itest + +import ( + "flag" + "fmt" + "math/rand" + "strings" + "testing" + "time" + + "github.com/btcsuite/btcd/integration/rpctest" + "github.com/btcsuite/btcwallet/bwtest" + "github.com/btcsuite/btcwallet/chain/port" +) + +var ( + // chainBackend defines the blockchain backend to be used for the + // integration tests. + // Options: "btcd" (default), "bitcoind", "neutrino". + chainBackend = flag.String( + "chain", "btcd", + "chain backend to use (btcd, bitcoind, neutrino)", + ) + + // dbBackend defines the database backend to be used for the wallet + // storage. + // Options: "kvdb" (default), "sqlite", "postgres". + // + // This flag allows verifying that the wallet functions correctly across all + // supported database drivers. + dbBackend = flag.String( + "db", "kvdb", + "database backend to use (kvdb, sqlite, postgres)", + ) + + // shuffleSeedFlag is the source of randomness used to shuffle the test + // cases. If not specified, the test cases won't be shuffled. + shuffleSeedFlag = flag.Uint64( + "shuffleseed", 0, + "if set, shuffles the test cases using this as the source of "+ + "randomness", + ) +) + +// init configures rpctest to allocate unique listener ports. +func init() { + // Use system-unique ports for rpctest harnesses so multiple local test runs + // don't collide. + rpctest.ListenAddressGenerator = func() (string, string) { + p2p := fmt.Sprintf(rpctest.ListenerFormat, port.NextAvailablePort()) + rpc := fmt.Sprintf(rpctest.ListenerFormat, port.NextAvailablePort()) + + return p2p, rpc + } +} + +// TestBtcWallet runs the btcwallet integration test suite. +func TestBtcWallet(t *testing.T) { + if len(allTestCases) == 0 { + t.Skip("no integration test cases registered") + } + + maybeShuffleTestCases() + + harness := bwtest.SetupHarness(t, *chainBackend, *dbBackend) + + for _, tc := range allTestCases { + if tc == nil { + continue + } + + validateTestCaseName(t, tc.Name) + + name := fmt.Sprintf("%s/%s", *chainBackend, tc.Name) + + success := t.Run(name, func(st *testing.T) { + ht := harness.Subtest(st) + ht.RunTestCase(tc.Name, tc.TestFunc) + }) + if !success { + t.Logf("failure time: %v", time.Now().Format( + "2006-01-02 15:04:05.000", + )) + + break + } + } +} + +// maybeShuffleTestCases shuffles the test cases if the flag `shuffleseed` is +// set and not 0. +func maybeShuffleTestCases() { + // Exit if set to 0. + if *shuffleSeedFlag == 0 { + return + } + + r := rand.New(rand.NewSource(int64(*shuffleSeedFlag))) + r.Shuffle(len(allTestCases), func(i, j int) { + allTestCases[i], allTestCases[j] = allTestCases[j], allTestCases[i] + }) +} + +// validateTestCaseName enforces a consistent naming convention for integration +// test cases. +// +// Names must be in the format "component action" (space separated), and must +// not include underscores. +func validateTestCaseName(t *testing.T, name string) { + t.Helper() + + if strings.Contains(name, "_") { + t.Fatalf("invalid test case name %q: underscores are not allowed", + name) + } + + words := strings.Fields(name) + if len(words) < 2 { + t.Fatalf("invalid test case name %q: want 'component action'", + name) + } +} diff --git a/itest/manager_test.go b/itest/manager_test.go new file mode 100644 index 0000000000..98444596d1 --- /dev/null +++ b/itest/manager_test.go @@ -0,0 +1,55 @@ +//go:build itest + +package itest + +import ( + "context" + "time" + + "github.com/btcsuite/btcwallet/bwtest" + "github.com/btcsuite/btcwallet/wallet" + "github.com/stretchr/testify/require" +) + +// testCreateWallet verifies a wallet can be created, started, and synced. +func testCreateWallet(h *bwtest.HarnessTest) { + h.Helper() + + // Create a wallet using the Manager API. + cfg := wallet.Config{ + DB: h.WalletDB, + Chain: h.ChainClient, + ChainParams: h.NetParams(), + RecoveryWindow: 20, + WalletSyncRetryInterval: 500 * time.Millisecond, + Name: "testwallet", + PubPassphrase: []byte("public"), + } + + manager := wallet.NewManager() + params := wallet.CreateWalletParams{ + Mode: wallet.ModeGenSeed, + PubPassphrase: []byte("public"), + PrivatePassphrase: []byte("private"), + Birthday: time.Now().Add(-1 * time.Hour), + } + + w, err := manager.Create(cfg, params) + require.NoError(h, err, "failed to create wallet") + + err = w.Start(h.Context()) + require.NoError(h, err, "failed to start wallet") + h.Cleanup(func() { + // We use a background context here because h.Context() might be + // cancelled already. + require.NoError( + h, w.Stop(context.Background()), "failed to stop wallet", + ) + }) + + // Register the wallet so harness helpers can assert global invariants. + h.RegisterWallet(w) + + // Mine a few blocks and require the wallet catches up. + h.MineBlocks(5) +} diff --git a/make/testing_flags.mk b/make/testing_flags.mk index a2266c0642..6fae8ee206 100644 --- a/make/testing_flags.mk +++ b/make/testing_flags.mk @@ -1,5 +1,24 @@ DEV_TAGS = dev LOG_TAGS = +IT_TAGS ?= + +# Integration test DB selections derived from the `db` variable (sqlite, postgres). +# Defaults to sqlite if not specified. +ifeq ($(db),postgres) +IT_TAGS += test_db_postgres +IT_DB_LABEL := PostgreSQL +IT_DB_TYPE := postgres +else +IT_TAGS := +IT_DB_LABEL := SQLite +IT_DB_TYPE := sqlite +endif + +# Enable integration test coverage +ifeq ($(cover),1) +ITEST_DB_COVERPROFILE = coverage-itest-$(IT_DB_TYPE).txt +ITEST_DB_COVERAGE = -coverprofile=$(ITEST_DB_COVERPROFILE) -coverpkg=$(PKG)/wallet/internal/db/... -covermode=atomic +endif GOCC ?= go GOLIST := $(GOCC) list -tags="$(DEV_TAGS)" -deps $(PKG)/... | grep '$(PKG)' @@ -39,6 +58,10 @@ ifneq ($(nocache),) TEST_FLAGS += -test.count=1 endif +ifneq ($(benchmem),) +TEST_FLAGS += -test.benchmem +endif + # Define the log tags that will be applied only when running unit tests. If none # are provided, we default to "debug stdlog" which will be standard debug log # output. @@ -61,7 +84,7 @@ UNIT_RACE := $(GOTEST) -tags="$(DEV_TAGS) $(LOG_TAGS)" $(TEST_FLAGS) -race $(UNI # NONE is a special value which selects no other tests but only executes the # benchmark tests here. -UNIT_BENCH := $(GOTEST) -tags="$(DEV_TAGS) $(LOG_TAGS)" -test.bench=. -test.run=NONE $(UNITPKG) +UNIT_BENCH := $(GOTEST) -tags="$(DEV_TAGS) $(LOG_TAGS)" $(TEST_FLAGS) -test.bench=. -test.run=NONE $(UNITPKG) endif ifeq ($(UNIT_TARGETED), no) @@ -70,8 +93,11 @@ UNIT_DEBUG := $(GOLIST) | $(XARGS) env $(GOTEST) -v -tags="$(DEV_TAGS) $(LOG_TAG # NONE is a special value which selects no other tests but only executes the # benchmark tests here. -UNIT_BENCH := $(GOLIST) | $(XARGS) env $(GOTEST) -tags="$(DEV_TAGS) $(LOG_TAGS)" -test.bench=. -test.run=NONE +UNIT_BENCH := $(GOLIST) | $(XARGS) env $(GOTEST) -tags="$(DEV_TAGS) $(LOG_TAGS)" $(TEST_FLAGS) -test.bench=. -test.run=NONE UNIT_RACE := $(UNIT) -race endif UNIT_COVER := $(GOTEST) $(COVER_FLAGS) -tags="$(DEV_TAGS) $(LOG_TAGS)" $(TEST_FLAGS) $(COVER_PKG) + +ITEST_DB := $(GOTEST) $(ITEST_DB_COVERAGE) -tags="itest $(DEV_TAGS) $(LOG_TAGS) $(IT_TAGS)" $(TEST_FLAGS) $(PKG)/wallet/internal/db/itest +ITEST_DB_RACE := $(GOTEST) -race -tags="itest $(DEV_TAGS) $(LOG_TAGS) $(IT_TAGS)" $(TEST_FLAGS) $(PKG)/wallet/internal/db/itest diff --git a/pkg/btcunit/README.md b/pkg/btcunit/README.md new file mode 100644 index 0000000000..ed4abef300 --- /dev/null +++ b/pkg/btcunit/README.md @@ -0,0 +1,25 @@ +# btcwallet/btcunit + +This package provides a set of idiomatic, type-safe units for handling common +Bitcoin quantities like transaction sizes, weights, and fee rates. + +## Purpose + +In complex Bitcoin applications, it is crucial to handle different units of +measurement safely and consistently. Raw integer types can lead to ambiguity and +errors (e.g., is a fee rate in sat/byte, sat/vbyte, or sat/kw?). + +This package establishes a canonical set of types to be used within `btcwallet` +and by any application that consumes it. By using these types, developers can +avoid conversion errors and make their code more readable and self-documenting. + +## Provided Units + +- **Transaction Size**: `WeightUnit` and `VByte` for handling transaction + weight and virtual size according to SegWit (BIP-141) standards. +- **Fee Rates**: `SatPerVByte`, `SatPerKVByte`, and `SatPerKWeight` for + expressing fee rates in the most common industry formats. These types use + `math/big.Rat` internally to allow for fractional (sub-satoshi) values, + ensuring precision in fee calculations. These types use + `math/big.Rat` internally to allow for fractional (sub-satoshi) values, + ensuring precision in fee calculations. diff --git a/pkg/btcunit/rates.go b/pkg/btcunit/rates.go new file mode 100644 index 0000000000..7ca05ee98f --- /dev/null +++ b/pkg/btcunit/rates.go @@ -0,0 +1,482 @@ +// Copyright (c) 2025 The btcsuite developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +// Package btcunit provides a set of types for dealing with bitcoin units. +package btcunit + +import ( + "log/slog" + "math" + "math/big" + + "github.com/btcsuite/btcd/blockchain" + "github.com/btcsuite/btcd/btcutil" +) + +const ( + // kilo is a generic multiplier for kilo units. + kilo = 1000 + + // floatStringPrecision is the number of decimal places to use when + // converting a fee rate to a string. We use 3 decimal places to ensure + // that low fee rates (e.g., 1 sat/kvb = 0.001 sat/vbyte) are displayed + // with sufficient precision and not rounded to zero. + floatStringPrecision = 3 +) + +var ( + // ZeroSatPerVByte is a fee rate of 0 sat/vb. + ZeroSatPerVByte = NewSatPerVByte(0) + + // ZeroSatPerKVByte is a fee rate of 0 sat/kvb. + ZeroSatPerKVByte = NewSatPerKVByte(0) + + // ZeroSatPerKWeight is a fee rate of 0 sat/kw. + ZeroSatPerKWeight = NewSatPerKWeight(0) + + // ZeroSatPerWeight is a fee rate of 0 sat/wu. + ZeroSatPerWeight = NewSatPerWeight(0) +) + +// baseFeeRate stores the canonical representation of a fee rate, which is +// satoshis per kilo-weight-unit (sat/kwu). All other fee rate units are +// derived from this. +type baseFeeRate struct { + // satsPerKWU is the fee rate in satoshis per kilo-weight-unit. This is + // the canonical representation for all fee rates within this package, + // chosen for its direct alignment with Bitcoin's weight unit for fee + // calculations and to minimize rounding errors. + satsPerKWU *big.Rat +} + +// newBaseFeeRate creates a new baseFeeRate with the given numerator and +// denominator. It panics if the denominator is zero. +func newBaseFeeRate(numerator btcutil.Amount, denominator uint64) baseFeeRate { + if denominator == 0 { + panic("fee rate calculation: denominator cannot be zero") + } + + return baseFeeRate{satsPerKWU: big.NewRat( + int64(numerator), + safeUint64ToInt64(denominator), + )} +} + +// ToSatPerVByte converts the fee rate to sat/vb. +func (f baseFeeRate) ToSatPerVByte() SatPerVByte { + return SatPerVByte{f} +} + +// ToSatPerKVByte converts the fee rate to sat/kvb. +func (f baseFeeRate) ToSatPerKVByte() SatPerKVByte { + return SatPerKVByte{f} +} + +// ToSatPerKWeight converts the fee rate to sat/kw. +func (f baseFeeRate) ToSatPerKWeight() SatPerKWeight { + return SatPerKWeight{f} +} + +// ToSatPerWeight converts the fee rate to sat/wu. +func (f baseFeeRate) ToSatPerWeight() SatPerWeight { + return SatPerWeight{f} +} + +// FeeForWeight calculates the fee resulting from this fee rate and the given +// weight in weight units (wu). +func (f baseFeeRate) FeeForWeight(weightUnit WeightUnit) btcutil.Amount { + // The fee rate is stored as satoshis per kilo-weight-unit (sat/kwu). + // To calculate the fee for a given weight, we need to multiply the + // rate by the weight expressed in kilo-weight-units. We do this by + // creating a rational number of weightUnit.wu / kilo. + // + // The resulting fee is rounded down (truncated). + feeRateRational := big.NewRat(0, 1) + feeRateRational.Mul( + f.satsPerKWU, + big.NewRat(safeUint64ToInt64(weightUnit.wu), kilo), + ) + + // Extract the numerator and denominator for integer division. + numerator := feeRateRational.Num() + denominator := feeRateRational.Denom() + + // Perform integer division to truncate the result (round down). + quotient := big.NewInt(0) + quotient.Div(numerator, denominator) + + return btcutil.Amount(quotient.Int64()) +} + +// FeeForWeightRoundUp calculates the fee resulting from this fee rate and the +// given weight in weight units (wu), rounding up to the nearest satoshi. +func (f baseFeeRate) FeeForWeightRoundUp(weightUnit WeightUnit) btcutil.Amount { + // The rounding logic for ceiling division is based on the formula: + // (numerator + denominator - 1) / denominator + // This ensures that any fractional part of the fee is rounded up to + // the next whole satoshi. + // + // Calculate the fee rate as a rational number. + feeRateRational := big.NewRat(0, 1) + feeRateRational.Mul( + f.satsPerKWU, big.NewRat( + safeUint64ToInt64(weightUnit.wu), kilo, + ), + ) + + // Get the numerator and denominator of the calculated fee. + numerator := feeRateRational.Num() + denominator := feeRateRational.Denom() + + // Initialize a new big.Int to store the result of the ceiling division. + result := big.NewInt(0) + + // Apply the ceiling division formula: + // (numerator + denominator - 1) / denominator. + result.Add(numerator, denominator) + result.Sub(result, big.NewInt(1)) + result.Div(result, denominator) + + return btcutil.Amount(result.Int64()) +} + +// FeeForVByte calculates the fee resulting from this fee rate and the given +// size in vbytes (vb). +func (f baseFeeRate) FeeForVByte(vb VByte) btcutil.Amount { + return f.FeeForWeight(vb.ToWU()) +} + +// FeeForKVByte calculates the fee resulting from this fee rate and the given +// vsize in kilo-vbytes. +func (f baseFeeRate) FeeForKVByte(kvb KVByte) btcutil.Amount { + // Directly convert kilo-virtual-bytes to weight units for fee + // calculation to maintain precision and avoid intermediate rounding + // effects. + return f.FeeForWeight(kvb.ToWU()) +} + +// FeeForKWeight calculates the fee resulting from this fee rate and the given +// weight in kilo-weight-units (kwu). +func (f baseFeeRate) FeeForKWeight(kwu KWeightUnit) btcutil.Amount { + return f.FeeForWeight(kwu.ToWU()) +} + +// equal returns true if the fee rate is equal to the other fee rate. +func (f baseFeeRate) equal(other baseFeeRate) bool { + return f.satsPerKWU.Cmp(other.satsPerKWU) == 0 +} + +// greaterThan returns true if the fee rate is greater than the other fee rate. +func (f baseFeeRate) greaterThan(other baseFeeRate) bool { + return f.satsPerKWU.Cmp(other.satsPerKWU) > 0 +} + +// lessThan returns true if the fee rate is less than the other fee rate. +func (f baseFeeRate) lessThan(other baseFeeRate) bool { + return f.satsPerKWU.Cmp(other.satsPerKWU) < 0 +} + +// greaterThanOrEqual returns true if the fee rate is greater than or equal to +// the other fee rate. +func (f baseFeeRate) greaterThanOrEqual(other baseFeeRate) bool { + return f.satsPerKWU.Cmp(other.satsPerKWU) >= 0 +} + +// lessThanOrEqual returns true if the fee rate is less than or equal to the +// other fee rate. +func (f baseFeeRate) lessThanOrEqual(other baseFeeRate) bool { + return f.satsPerKWU.Cmp(other.satsPerKWU) <= 0 +} + +// SatPerVByte represents a fee rate in sat/vbyte. Internally, all fee rates +// are stored and operated on as satoshis per kilo-weight-unit (sat/kw). +// Conversions to other units and fee calculations are performed using this +// canonical internal representation. The `String()` method is the only one +// that presents the fee rate in its specific sat/vbyte unit. +type SatPerVByte struct { + baseFeeRate +} + +// NewSatPerVByte creates a new fee rate in sat/vb. +func NewSatPerVByte(rate btcutil.Amount) SatPerVByte { + return CalcSatPerVByte(rate, NewVByte(1)) +} + +// CalcSatPerVByte calculates the fee rate in sat/vb for a given fee and size. +func CalcSatPerVByte(fee btcutil.Amount, vb VByte) SatPerVByte { + // To convert the rate to the canonical sat/kwu unit, we use the + // formula: (fee * 1000) / size_in_wu. + // + // vb.wu provides the size in weight units (wu), implicitly accounting + // for the WitnessScaleFactor. + numerator := fee * kilo + denominator := vb.wu + + return SatPerVByte{newBaseFeeRate(numerator, denominator)} +} + +// String returns a human-readable string of the fee rate. +func (s SatPerVByte) String() string { + // Calculate the fee rate in sat/vb from the canonical sat/kwu. + // The WitnessScaleFactor (4) is used to convert weight units to vbytes. + // The `kilo` constant is used to scale kilo-weight-units. + kwToVbRate := big.NewRat(0, 1) + kwToVbRate.Mul(s.satsPerKWU, + big.NewRat(blockchain.WitnessScaleFactor, kilo), + ) + + // Format the rational number to a string with the specified precision. + return kwToVbRate.FloatString(floatStringPrecision) + " sat/vb" +} + +// Equal returns true if the fee rate is equal to the other fee rate. +func (s SatPerVByte) Equal(other SatPerVByte) bool { + return s.equal(other.baseFeeRate) +} + +// GreaterThan returns true if the fee rate is greater than the other fee rate. +func (s SatPerVByte) GreaterThan(other SatPerVByte) bool { + return s.greaterThan(other.baseFeeRate) +} + +// LessThan returns true if the fee rate is less than the other fee rate. +func (s SatPerVByte) LessThan(other SatPerVByte) bool { + return s.lessThan(other.baseFeeRate) +} + +// GreaterThanOrEqual returns true if the fee rate is greater than or equal to +// the other fee rate. +func (s SatPerVByte) GreaterThanOrEqual(other SatPerVByte) bool { + return s.greaterThanOrEqual(other.baseFeeRate) +} + +// LessThanOrEqual returns true if the fee rate is less than or equal to the +// other fee rate. +func (s SatPerVByte) LessThanOrEqual(other SatPerVByte) bool { + return s.lessThanOrEqual(other.baseFeeRate) +} + +// SatPerKVByte represents a fee rate in sat/kvb. Internally, all fee rates +// are stored and operated on as satoshis per kilo-weight-unit (sat/kw). +// Conversions to other units and fee calculations are performed using this +// canonical internal representation. The `String()` method is the only one +// that presents the fee rate in its specific sat/kvb unit. +type SatPerKVByte struct { + baseFeeRate +} + +// NewSatPerKVByte creates a new fee rate in sat/kvb. +func NewSatPerKVByte(rate btcutil.Amount) SatPerKVByte { + return CalcSatPerKVByte(rate, NewKVByte(1)) +} + +// CalcSatPerKVByte calculates the fee rate in sat/kvb for a given fee and size. +func CalcSatPerKVByte(fee btcutil.Amount, kvb KVByte) SatPerKVByte { + // To convert the rate to the canonical sat/kwu unit, we use the + // formula: (fee * 1000) / size_in_wu. + // + // kvb.wu provides the size in weight units (wu), implicitly accounting + // for the WitnessScaleFactor and kilo scaling. + numerator := fee * kilo + denominator := kvb.wu + + return SatPerKVByte{newBaseFeeRate(numerator, denominator)} +} + +// Val returns the fee rate in sat/kvb. +// +// NOTE: This method is provided for backward compatibility with legacy APIs +// that expect a raw integer fee rate. New code should use the btcunit types +// directly. +func (s SatPerKVByte) Val() btcutil.Amount { + return s.FeeForKVByte(NewKVByte(1)) +} + +// String returns a human-readable string of the fee rate. +func (s SatPerKVByte) String() string { + // Calculate the fee rate in sat/kvb from the canonical sat/kwu. + // The WitnessScaleFactor (4) is used to convert weight units to vbytes. + // No `kilo` division here as we are converting to *kilo*-vbytes. + kwToKvbRate := big.NewRat(0, 1) + kwToKvbRate.Mul(s.satsPerKWU, + big.NewRat(blockchain.WitnessScaleFactor, 1), + ) + + // Format the rational number to a string with the specified precision. + return kwToKvbRate.FloatString(floatStringPrecision) + + " sat/kvb" +} + +// Equal returns true if the fee rate is equal to the other fee rate. +func (s SatPerKVByte) Equal(other SatPerKVByte) bool { + return s.equal(other.baseFeeRate) +} + +// GreaterThan returns true if the fee rate is greater than the other fee rate. +func (s SatPerKVByte) GreaterThan(other SatPerKVByte) bool { + return s.greaterThan(other.baseFeeRate) +} + +// LessThan returns true if the fee rate is less than the other fee rate. +func (s SatPerKVByte) LessThan(other SatPerKVByte) bool { + return s.lessThan(other.baseFeeRate) +} + +// GreaterThanOrEqual returns true if the fee rate is greater than or equal to +// the other fee rate. +func (s SatPerKVByte) GreaterThanOrEqual(other SatPerKVByte) bool { + return s.greaterThanOrEqual(other.baseFeeRate) +} + +// LessThanOrEqual returns true if the fee rate is less than or equal to the +// other fee rate. +func (s SatPerKVByte) LessThanOrEqual(other SatPerKVByte) bool { + return s.lessThanOrEqual(other.baseFeeRate) +} + +// SatPerKWeight represents a fee rate in sat/kw. Internally, all fee rates +// are stored and operated on as satoshis per kilo-weight-unit (sat/kw). +// Conversions to other units and fee calculations are performed using this +// canonical internal representation. The `String()` method is the only one +// that presents the fee rate in its specific sat/kw unit. +type SatPerKWeight struct { + baseFeeRate +} + +// NewSatPerKWeight creates a new fee rate in sat/kw. +func NewSatPerKWeight(rate btcutil.Amount) SatPerKWeight { + return CalcSatPerKWeight(rate, NewKWeightUnit(1)) +} + +// CalcSatPerKWeight calculates the fee rate in sat/kw for a given fee and size. +func CalcSatPerKWeight(fee btcutil.Amount, kwu KWeightUnit) SatPerKWeight { + // To convert the rate to the canonical sat/kwu unit, we use the + // formula: (fee * 1000) / size_in_wu. + // + // kwu.wu provides the size in weight units (wu), implicitly accounting + // for the kilo scaling. + numerator := fee * kilo + denominator := kwu.wu + + return SatPerKWeight{newBaseFeeRate(numerator, denominator)} +} + +// Val returns the fee rate in sat/kw. +// +// NOTE: This method is provided for backward compatibility with legacy APIs +// that expect a raw integer fee rate. New code should use the btcunit types +// directly. +func (s SatPerKWeight) Val() btcutil.Amount { + return s.FeeForKWeight(NewKWeightUnit(1)) +} + +// String returns a human-readable string of the fee rate. +func (s SatPerKWeight) String() string { + return s.satsPerKWU.FloatString(floatStringPrecision) + " sat/kw" +} + +// Equal returns true if the fee rate is equal to the other fee rate. +func (s SatPerKWeight) Equal(other SatPerKWeight) bool { + return s.equal(other.baseFeeRate) +} + +// GreaterThan returns true if the fee rate is greater than the other fee rate. +func (s SatPerKWeight) GreaterThan(other SatPerKWeight) bool { + return s.greaterThan(other.baseFeeRate) +} + +// LessThan returns true if the fee rate is less than the other fee rate. +func (s SatPerKWeight) LessThan(other SatPerKWeight) bool { + return s.lessThan(other.baseFeeRate) +} + +// GreaterThanOrEqual returns true if the fee rate is greater than or equal to +// the other fee rate. +func (s SatPerKWeight) GreaterThanOrEqual(other SatPerKWeight) bool { + return s.greaterThanOrEqual(other.baseFeeRate) +} + +// LessThanOrEqual returns true if the fee rate is less than or equal to the +// other fee rate. +func (s SatPerKWeight) LessThanOrEqual(other SatPerKWeight) bool { + return s.lessThanOrEqual(other.baseFeeRate) +} + +// SatPerWeight represents a fee rate in sat/wu. Internally, all fee rates +// are stored and operated on as satoshis per kilo-weight-unit (sat/kw). +// Conversions to other units and fee calculations are performed using this +// canonical internal representation. The `String()` method is the only one +// that presents the fee rate in its specific sat/wu unit. +type SatPerWeight struct { + baseFeeRate +} + +// NewSatPerWeight creates a new fee rate in sat/wu. +func NewSatPerWeight(rate btcutil.Amount) SatPerWeight { + return CalcSatPerWeight(rate, NewWeightUnit(1)) +} + +// CalcSatPerWeight calculates the fee rate in sat/wu for a given fee and size. +func CalcSatPerWeight(fee btcutil.Amount, wu WeightUnit) SatPerWeight { + // To convert the rate to the canonical sat/kwu unit, we use the + // formula: (fee * 1000) / size_in_wu. + // + // wu.wu provides the size in weight units (wu). + numerator := fee * kilo + denominator := wu.wu + + return SatPerWeight{newBaseFeeRate(numerator, denominator)} +} + +// String returns a human-readable string of the fee rate. +func (s SatPerWeight) String() string { + // Calculate the fee rate in sat/wu from the canonical sat/kwu. + // 1 sat/wu = 1000 sat/kwu. So we need to divide by kilo. + wuRate := big.NewRat(0, 1) + wuRate.Mul(s.satsPerKWU, big.NewRat(1, kilo)) + + return wuRate.FloatString(floatStringPrecision) + " sat/wu" +} + +// Equal returns true if the fee rate is equal to the other fee rate. +func (s SatPerWeight) Equal(other SatPerWeight) bool { + return s.equal(other.baseFeeRate) +} + +// GreaterThan returns true if the fee rate is greater than the other fee rate. +func (s SatPerWeight) GreaterThan(other SatPerWeight) bool { + return s.greaterThan(other.baseFeeRate) +} + +// LessThan returns true if the fee rate is less than the other fee rate. +func (s SatPerWeight) LessThan(other SatPerWeight) bool { + return s.lessThan(other.baseFeeRate) +} + +// GreaterThanOrEqual returns true if the fee rate is greater than or equal to +// the other fee rate. +func (s SatPerWeight) GreaterThanOrEqual(other SatPerWeight) bool { + return s.greaterThanOrEqual(other.baseFeeRate) +} + +// LessThanOrEqual returns true if the fee rate is less than or equal to the +// other fee rate. +func (s SatPerWeight) LessThanOrEqual(other SatPerWeight) bool { + return s.lessThanOrEqual(other.baseFeeRate) +} + +// safeUint64ToInt64 converts a uint64 to an int64, capping at math.MaxInt64. +// This is used to silence gosec warnings about integer overflows. In practice, +// the values being converted are transaction weights or sizes, which are +// limited by consensus rules and are not expected to overflow an int64. +func safeUint64ToInt64(u uint64) int64 { + if u > math.MaxInt64 { + slog.Warn("Capping uint64 value to math.MaxInt64", + slog.Uint64("old", u), slog.Int64("new", math.MaxInt64)) + + return math.MaxInt64 + } + + return int64(u) +} diff --git a/pkg/btcunit/rates_test.go b/pkg/btcunit/rates_test.go new file mode 100644 index 0000000000..02da8eaeb2 --- /dev/null +++ b/pkg/btcunit/rates_test.go @@ -0,0 +1,737 @@ +package btcunit + +import ( + "math" + "math/big" + "testing" + + "github.com/btcsuite/btcd/btcutil" + "github.com/stretchr/testify/require" +) + +// TestFeeRateConversions checks that the conversion between the different fee +// rate units is correct. +func TestFeeRateConversions(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + rate any + expectedVB SatPerVByte + expectedKVB SatPerKVByte + expectedKW SatPerKWeight + expectedW SatPerWeight + expectedSats btcutil.Amount + }{ + { + name: "1 sat/vb", + rate: NewSatPerVByte(1), + expectedVB: NewSatPerVByte(1), + expectedKVB: NewSatPerKVByte(1000), + expectedKW: NewSatPerKWeight(250), + expectedW: CalcSatPerWeight(1, NewWeightUnit(4)), + expectedSats: 250, + }, + { + name: "1000 sat/kvb", + rate: NewSatPerKVByte(1000), + expectedVB: NewSatPerVByte(1), + expectedKVB: NewSatPerKVByte(1000), + expectedKW: NewSatPerKWeight(250), + expectedW: CalcSatPerWeight(1, NewWeightUnit(4)), + expectedSats: 250, + }, + { + name: "250 sat/kw", + rate: NewSatPerKWeight(250), + expectedVB: NewSatPerVByte(1), + expectedKVB: NewSatPerKVByte(1000), + expectedKW: NewSatPerKWeight(250), + expectedW: CalcSatPerWeight(1, NewWeightUnit(4)), + expectedSats: 250, + }, + { + name: "0.25 sat/wu", + rate: CalcSatPerWeight(1, NewWeightUnit(4)), + expectedVB: NewSatPerVByte(1), + expectedKVB: NewSatPerKVByte(1000), + expectedKW: NewSatPerKWeight(250), + expectedW: CalcSatPerWeight(1, NewWeightUnit(4)), + expectedSats: 250, + }, + { + name: "0.11 sat/vb", + rate: CalcSatPerVByte(11, NewVByte(100)), + expectedVB: CalcSatPerVByte(11, NewVByte(100)), + expectedKVB: NewSatPerKVByte(110), + expectedKW: CalcSatPerKWeight(55, NewKWeightUnit(2)), + expectedW: CalcSatPerWeight(11, NewWeightUnit(400)), + expectedSats: 27, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + switch r := tc.rate.(type) { + case SatPerVByte: + require.True(t, tc.expectedVB.equal( + r.ToSatPerVByte().baseFeeRate, + )) + require.True(t, tc.expectedKVB.equal( + r.ToSatPerKVByte().baseFeeRate, + )) + require.True(t, tc.expectedKW.equal( + r.ToSatPerKWeight().baseFeeRate, + )) + require.True(t, tc.expectedW.equal( + r.ToSatPerWeight().baseFeeRate, + )) + + // Calculate the floor of the fee rate. + floor := big.NewInt(0) + floor.Div( + r.satsPerKWU.Num(), + r.satsPerKWU.Denom(), + ) + require.Equal( + t, tc.expectedSats, + btcutil.Amount(floor.Int64()), + ) + + case SatPerKVByte: + require.True(t, tc.expectedVB.equal( + r.ToSatPerVByte().baseFeeRate, + )) + require.True( + t, tc.expectedKVB.equal(r.baseFeeRate), + ) + require.True(t, tc.expectedKW.equal( + r.ToSatPerKWeight().baseFeeRate, + )) + require.True(t, tc.expectedW.equal( + r.ToSatPerWeight().baseFeeRate, + )) + + // Calculate the floor of the fee rate. + floor := big.NewInt(0) + floor.Div( + r.satsPerKWU.Num(), + r.satsPerKWU.Denom(), + ) + require.Equal( + t, tc.expectedSats, + btcutil.Amount(floor.Int64()), + ) + + case SatPerKWeight: + require.True(t, + tc.expectedVB.equal( + r.ToSatPerVByte().baseFeeRate, + ), + ) + require.True(t, tc.expectedKVB.equal( + r.ToSatPerKVByte().baseFeeRate, + )) + require.True( + t, tc.expectedKW.equal(r.baseFeeRate), + ) + require.True(t, tc.expectedW.equal( + r.ToSatPerWeight().baseFeeRate, + )) + + // Calculate the floor of the fee rate. + floor := big.NewInt(0) + floor.Div( + r.satsPerKWU.Num(), + r.satsPerKWU.Denom(), + ) + require.Equal( + t, tc.expectedSats, + btcutil.Amount(floor.Int64()), + ) + + case SatPerWeight: + require.True(t, tc.expectedVB.equal( + r.ToSatPerVByte().baseFeeRate, + )) + require.True(t, tc.expectedKVB.equal( + r.ToSatPerKVByte().baseFeeRate, + )) + require.True(t, tc.expectedKW.equal( + r.ToSatPerKWeight().baseFeeRate, + )) + require.True( + t, tc.expectedW.equal(r.baseFeeRate), + ) + + // Calculate the floor of the fee rate. + floor := big.NewInt(0) + floor.Div( + r.satsPerKWU.Num(), + r.satsPerKWU.Denom(), + ) + require.Equal( + t, tc.expectedSats, + btcutil.Amount(floor.Int64()), + ) + } + }) + } +} + +// TestFeeRateComparisonsVB tests the comparison methods of the SatPerVByte +// type. +func TestFeeRateComparisonsVB(t *testing.T) { + t.Parallel() + + // Create a set of fee rates to compare. + r1 := NewSatPerVByte(1) + r2 := NewSatPerVByte(2) + r3 := NewSatPerVByte(1) + + // Test Equal. + require.True(t, r1.Equal(r3)) + require.False(t, r1.Equal(r2)) + + // Test GreaterThan. + require.True(t, r2.GreaterThan(r1)) + require.False(t, r1.GreaterThan(r2)) + require.False(t, r1.GreaterThan(r3)) + + // Test LessThan. + require.True(t, r1.LessThan(r2)) + require.False(t, r2.LessThan(r1)) + require.False(t, r1.LessThan(r3)) + + // Test GreaterThanOrEqual. + require.True(t, r2.GreaterThanOrEqual(r1)) + require.True(t, r1.GreaterThanOrEqual(r3)) + require.False(t, r1.GreaterThanOrEqual(r2)) + + // Test LessThanOrEqual. + require.True(t, r1.LessThanOrEqual(r2)) + require.True(t, r1.LessThanOrEqual(r3)) + require.False(t, r2.LessThanOrEqual(r1)) +} + +// TestFeeForWeightRoundUp checks that the FeeForWeightRoundUp method correctly +// rounds up the fee for a given weight. +func TestFeeForWeightRoundUp(t *testing.T) { + t.Parallel() + + feeRate := NewSatPerVByte(1).ToSatPerKWeight() + txWeight := NewWeightUnit(674) // 674 weight units is 168.5 vb. + + require.EqualValues(t, 168, feeRate.FeeForWeight(txWeight)) + require.EqualValues(t, 169, feeRate.FeeForWeightRoundUp(txWeight)) +} + +// TestNewFeeRateConstructors checks that the New* and Calc* fee rate +// constructors work as expected. +func TestNewFeeRateConstructors(t *testing.T) { + t.Parallel() + + // Test CalcSatPerKWeight. + fee := btcutil.Amount(1000) + wu := NewWeightUnit(1000) + expectedRate := NewSatPerKWeight(1000) + require.Zero( + t, expectedRate.satsPerKWU.Cmp( + CalcSatPerKWeight(fee, wu.ToKWU()).satsPerKWU, + ), + ) + + // Test CalcSatPerWeight. + expectedRateW := NewSatPerWeight(1000) + require.Zero( + t, expectedRateW.satsPerKWU.Cmp( + CalcSatPerWeight(fee, NewWeightUnit(1)).satsPerKWU, + ), + ) + + // Test CalcSatPerVByte. + vb := NewVByte(250) + expectedRateVB := NewSatPerVByte(4) + require.Zero( + t, expectedRateVB.satsPerKWU.Cmp( + CalcSatPerVByte(fee, vb).satsPerKWU, + ), + ) + + // Test CalcSatPerKVByte. + kvb := NewKVByte(1) + expectedRateKVB := NewSatPerKVByte(1000) + require.Zero( + t, expectedRateKVB.satsPerKWU.Cmp( + CalcSatPerKVByte(fee, kvb).satsPerKWU, + ), + ) +} + +// TestStringer tests the stringer methods of the fee rate types. +func TestStringer(t *testing.T) { + t.Parallel() + + // Create a set of fee rates to test. + r1 := NewSatPerVByte(1) + r2 := NewSatPerKVByte(1000) + r3 := NewSatPerKWeight(250) + r4 := CalcSatPerWeight(1, NewWeightUnit(4)) // 0.25 sat/wu + + // Test String. + require.Equal(t, "1.000 sat/vb", r1.String()) + require.Equal(t, "1000.000 sat/kvb", r2.String()) + require.Equal(t, "250.000 sat/kw", r3.String()) + require.Equal(t, "0.250 sat/wu", r4.String()) +} + +// TestFeeRateComparisonsKVB tests the comparison methods of the SatPerKVByte +// type. +func TestFeeRateComparisonsKVB(t *testing.T) { + t.Parallel() + + // Create a set of fee rates to compare. + r1 := NewSatPerKVByte(1) + r2 := NewSatPerKVByte(2) + r3 := NewSatPerKVByte(1) + + // Test Equal. + require.True(t, r1.Equal(r3)) + require.False(t, r1.Equal(r2)) + + // Test GreaterThan. + require.True(t, r2.GreaterThan(r1)) + require.False(t, r1.GreaterThan(r2)) + require.False(t, r1.GreaterThan(r3)) + + // Test LessThan. + require.True(t, r1.LessThan(r2)) + require.False(t, r2.LessThan(r1)) + require.False(t, r1.LessThan(r3)) + + // Test GreaterThanOrEqual. + require.True(t, r2.GreaterThanOrEqual(r1)) + require.True(t, r1.GreaterThanOrEqual(r3)) + require.False(t, r1.GreaterThanOrEqual(r2)) + + // Test LessThanOrEqual. + require.True(t, r1.LessThanOrEqual(r2)) + require.True(t, r1.LessThanOrEqual(r3)) + require.False(t, r2.LessThanOrEqual(r1)) +} + +// TestFeeRateComparisonsKW tests the comparison methods of the SatPerKWeight +// type. +func TestFeeRateComparisonsKW(t *testing.T) { + t.Parallel() + + // Create a set of fee rates to compare. + r1 := NewSatPerKWeight(1) + r2 := NewSatPerKWeight(2) + r3 := NewSatPerKWeight(1) + + // Test Equal. + require.True(t, r1.Equal(r3)) + require.False(t, r1.Equal(r2)) + + // Test GreaterThan. + require.True(t, r2.GreaterThan(r1)) + require.False(t, r1.GreaterThan(r2)) + require.False(t, r1.GreaterThan(r3)) + + // Test LessThan. + require.True(t, r1.LessThan(r2)) + require.False(t, r2.LessThan(r1)) + require.False(t, r1.LessThan(r3)) + + // Test GreaterThanOrEqual. + require.True(t, r2.GreaterThanOrEqual(r1)) + require.True(t, r1.GreaterThanOrEqual(r3)) + require.False(t, r1.GreaterThanOrEqual(r2)) + + // Test LessThanOrEqual. + require.True(t, r1.LessThanOrEqual(r2)) + require.True(t, r1.LessThanOrEqual(r3)) + require.False(t, r2.LessThanOrEqual(r1)) +} + +// TestFeeRateComparisonsW tests the comparison methods of the SatPerWeight +// type. +func TestFeeRateComparisonsW(t *testing.T) { + t.Parallel() + + // Create a set of fee rates to compare. + r1 := NewSatPerWeight(1) + r2 := NewSatPerWeight(2) + r3 := NewSatPerWeight(1) + + // Test Equal. + require.True(t, r1.Equal(r3)) + require.False(t, r1.Equal(r2)) + + // Test GreaterThan. + require.True(t, r2.GreaterThan(r1)) + require.False(t, r1.GreaterThan(r2)) + require.False(t, r1.GreaterThan(r3)) + + // Test LessThan. + require.True(t, r1.LessThan(r2)) + require.False(t, r2.LessThan(r1)) + require.False(t, r1.LessThan(r3)) + + // Test GreaterThanOrEqual. + require.True(t, r2.GreaterThanOrEqual(r1)) + require.True(t, r1.GreaterThanOrEqual(r3)) + require.False(t, r1.GreaterThanOrEqual(r2)) + + // Test LessThanOrEqual. + require.True(t, r1.LessThanOrEqual(r2)) + require.True(t, r1.LessThanOrEqual(r3)) + require.False(t, r2.LessThanOrEqual(r1)) +} + +// TestFeeForSize tests the FeeForVSize and FeeForVByte methods. +func TestFeeForSize(t *testing.T) { + t.Parallel() + + // Create a set of fee rates to test. + // r1: 1000 sat/kvb = 1000 sat / 1000 vbyte = 1 sat/vbyte. + r1 := NewSatPerKVByte(1000) + + // r2: 250 sat/kwu. This matches r1. + r2 := NewSatPerKWeight(250) + + // r3: 1 sat/vbyte. + r3 := NewSatPerVByte(1) + + // r4: 0.25 sat/wu. + // 0.25 sat/wu * 1000 = 250 sat/kwu. + r4 := CalcSatPerWeight(1, NewWeightUnit(4)) + + // Test FeeForVByte with r1 (1000 sat/kvb). + // Size: 250 vbytes. + // Fee: 250 vbytes * 1 sat/vbyte = 250 sats. + require.Equal(t, btcutil.Amount(250), r1.FeeForVByte(NewVByte(250))) + + // Test FeeForVByte with r2 (250 sat/kwu). + // Size: 250 vbytes = 1000 weight units. + // Rate: 250 sat/1000 wu = 0.25 sat/wu. + // Fee: 1000 wu * 0.25 sat/wu = 250 sats. + require.Equal(t, btcutil.Amount(250), r2.FeeForVByte(NewVByte(250))) + + // Test FeeForVByte with SatPerVByte. + // Size: 1000 vbytes. + // Rate: 1 sat/vbyte. + // Fee: 1000 sats. + require.Equal(t, btcutil.Amount(1000), r3.FeeForVByte(NewVByte(1000))) + + // Test FeeForKVByte with SatPerVByte. + // Size: 1 kvb = 1000 vbytes. + // Rate: 1 sat/vbyte. + // Fee: 1000 sats. + require.Equal(t, btcutil.Amount(1000), r3.FeeForKVByte(NewKVByte(1))) + + // Test FeeForWeight with SatPerVByte. + // Size: 1000 weight units. + // Rate: 1 sat/vbyte = 0.25 sat/wu. + // Fee: 1000 * 0.25 = 250 sats. + require.Equal(t, btcutil.Amount(250), + r3.FeeForWeight(NewWeightUnit(1000))) + + // Test ToSatPerVByte with SatPerKVByte. + // 1000 sat/kvb should equal 1 sat/vbyte. + require.True(t, r3.Equal(r1.ToSatPerVByte())) + + // Test FeeForKVByte with SatPerKVByte. + // Size: 1 kvb. + // Rate: 1000 sat/kvb. + // Fee: 1000 sats. + require.Equal(t, btcutil.Amount(1000), r1.FeeForKVByte(NewKVByte(1))) + + // Test FeeForWeight with SatPerKVByte. + // Size: 1000 weight units. + // Rate: 1000 sat/kvb = 0.25 sat/wu. + // Fee: 1000 * 0.25 = 250 sats. + require.Equal(t, btcutil.Amount(250), + r1.FeeForWeight(NewWeightUnit(1000))) + + // Test FeeForKVByte with SatPerKWeight. + // Size: 1 kvb = 1000 vbytes = 4000 weight units. + // Rate: 250 sat/kwu = 0.25 sat/wu. + // Fee: 4000 * 0.25 = 1000 sats. + require.Equal(t, btcutil.Amount(1000), r2.FeeForKVByte(NewKVByte(1))) + + // Test FeeForKWeight with SatPerKWeight. + // Size: 1 kwu = 1000 weight units. + // Rate: 250 sat/kwu = 0.25 sat/wu. + // Fee: 1000 * 0.25 = 250 sats. + require.Equal(t, btcutil.Amount(250), + r2.FeeForKWeight(NewKWeightUnit(1))) + + // Test FeeForWeight with SatPerWeight. + // Size: 1000 weight units. + // Rate: 0.25 sat/wu. + // Fee: 1000 * 0.25 = 250 sats. + require.Equal(t, btcutil.Amount(250), + r4.FeeForWeight(NewWeightUnit(1000))) + + // Test ToSatPerWeight with SatPerVByte. + // 1 sat/vbyte should equal 0.25 sat/wu. + require.True(t, r4.Equal(r3.ToSatPerWeight())) +} + +// TestNewFeeRateConstructorsZero tests the New* fee rate constructors with +// zero values. +func TestNewFeeRateConstructorsZero(t *testing.T) { + t.Parallel() + + // Test CalcSatPerKWeight with zero weight should panic. + fee := btcutil.Amount(1000) + require.Panics(t, func() { + kwu := NewKWeightUnit(0) + _ = CalcSatPerKWeight(fee, kwu) + }) + + // Test CalcSatPerVByte with zero vbytes should panic. + require.Panics(t, func() { + vb := NewVByte(0) + _ = CalcSatPerVByte(fee, vb) + }) + + // Test CalcSatPerKVByte with zero kvbytes should panic. + require.Panics(t, func() { + kvb := NewKVByte(0) + _ = CalcSatPerKVByte(fee, kvb) + }) + + // Test CalcSatPerWeight with zero weight units should panic. + require.Panics(t, func() { + wu := NewWeightUnit(0) + _ = CalcSatPerWeight(fee, wu) + }) + + // Test zero constants. + // NewSatPerVByte(0) -> Rate 0 sats / 1 vb. Valid. + require.True(t, ZeroSatPerVByte.Equal(NewSatPerVByte(0))) + require.True(t, ZeroSatPerKVByte.Equal(NewSatPerKVByte(0))) + require.True(t, ZeroSatPerKWeight.Equal(NewSatPerKWeight(0))) + require.True(t, ZeroSatPerWeight.Equal(NewSatPerWeight(0))) + + require.Equal(t, "0.000 sat/vb", ZeroSatPerVByte.String()) + require.Equal(t, "0.000 sat/kvb", ZeroSatPerKVByte.String()) + require.Equal(t, "0.000 sat/kw", ZeroSatPerKWeight.String()) + require.Equal(t, "0.000 sat/wu", ZeroSatPerWeight.String()) +} + +// TestSafeUint64ToInt64Overflow tests the overflow condition in +// safeUint64ToInt64 through the New* constructors. +func TestSafeUint64ToInt64Overflow(t *testing.T) { + t.Parallel() + + fee := btcutil.Amount(1) + + // Test CalcSatPerVByte with an overflowing vbyte value. + // The denominator should be capped at math.MaxInt64. + // We manually construct the VByte to ensure wu > MaxInt64 without + // overflowing the constructor's internal multiplication. + overflowVByte := VByte{baseUnit{wu: math.MaxInt64 + 1}} + expectedDenom := big.NewInt(math.MaxInt64) + + rateVB := CalcSatPerVByte(fee, overflowVByte) + require.Zero(t, expectedDenom.Cmp(rateVB.satsPerKWU.Denom())) + + // Test CalcSatPerKVByte with an overflowing kvb value. + // The denominator should be capped at math.MaxInt64. + overflowKVByte := KVByte{baseUnit{wu: math.MaxInt64 + 1}} + rateKVB := CalcSatPerKVByte(fee, overflowKVByte) + require.Zero(t, expectedDenom.Cmp(rateKVB.satsPerKWU.Denom())) + + // Test CalcSatPerKWeight with an overflowing weight unit value. + overflowWU := KWeightUnit{baseUnit{wu: math.MaxInt64 + 1}} + rateKW := CalcSatPerKWeight(fee, overflowWU) + require.Zero(t, expectedDenom.Cmp(rateKW.satsPerKWU.Denom())) + + // Test CalcSatPerWeight with an overflowing weight unit value. + overflowWeight := WeightUnit{baseUnit{wu: math.MaxInt64 + 1}} + rateW := CalcSatPerWeight(fee, overflowWeight) + require.Zero(t, expectedDenom.Cmp(rateW.satsPerKWU.Denom())) +} + +// TestVal checks that the Val method returns the correct integer fee rate. +func TestVal(t *testing.T) { + t.Parallel() + + // Test SatPerKVByte.Val(). + rateKVB := NewSatPerKVByte(1000) + require.Equal(t, btcutil.Amount(1000), rateKVB.Val()) + + // Test SatPerKWeight.Val(). + rateKW := NewSatPerKWeight(250) + require.Equal(t, btcutil.Amount(250), rateKW.Val()) +} + +// TestRatePrecision checks that baseFeeRate preserves precision for +// non-integer rates (e.g., repeating decimals) during conversions and fee +// calculations for all rate units. +func TestRatePrecision(t *testing.T) { + t.Parallel() + + // We choose a test payload size of 12,000 weight units. + // This specific number is chosen because it is cleanly divisible by + // all unit factors, allowing us to pass exact integer amounts to all + // FeeFor... methods. + // + // 12,000 wu = 12 kwu + // 12,000 wu = 3,000 vb + // 12,000 wu = 3 kvb + const ( + payloadWU = 12000 + payloadKWU = 12 + payloadVB = 3000 + payloadKVB = 3 + ) + + // expectedFee is always 1 satoshi because we define the rate in each + // test case as (1 sat / payload_size). + const expectedFee = btcutil.Amount(1) + + // 1. Test SatPerWeight. + // Rate: 1 sat / 12,000 wu = 0.0000833... sat/wu. + t.Run("SatPerWeight", func(t *testing.T) { + t.Parallel() + + rate := CalcSatPerWeight(1, NewWeightUnit(payloadWU)) + + // The rate 0.0000833... rounds to 0.000 when displayed with 3 + // decimal places, but the internal precision is preserved. + require.Equal(t, "0.000 sat/wu", rate.String()) + require.Equal(t, expectedFee, + rate.FeeForWeight(NewWeightUnit(payloadWU))) + + // Convert to SatPerKWeight. + // Rate: 1 sat / 12 kwu = 0.0833... sat/kw. + kw := rate.ToSatPerKWeight() + require.Equal(t, "0.083 sat/kw", kw.String()) + require.Equal(t, expectedFee, + kw.FeeForKWeight(NewKWeightUnit(payloadKWU))) + + // Convert to SatPerVByte. + // Rate: 1 sat / 3,000 vb = 0.00033... sat/vb. + // This rounds to 0.000 at 3 decimals. + vb := rate.ToSatPerVByte() + require.Equal(t, "0.000 sat/vb", vb.String()) + require.Equal(t, expectedFee, + vb.FeeForVByte(NewVByte(payloadVB))) + + // Convert to SatPerKVByte. + // Rate: 1 sat / 3 kvb = 0.333... sat/kvb. + kvb := rate.ToSatPerKVByte() + require.Equal(t, "0.333 sat/kvb", kvb.String()) + require.Equal(t, expectedFee, + kvb.FeeForKVByte(NewKVByte(payloadKVB))) + }) + + // 2. Test SatPerKWeight. + // Rate: 1 sat / 12 kwu = 0.0833... sat/kw. + t.Run("SatPerKWeight", func(t *testing.T) { + t.Parallel() + + rate := CalcSatPerKWeight(1, NewKWeightUnit(payloadKWU)) + require.Equal(t, "0.083 sat/kw", rate.String()) + require.Equal(t, expectedFee, + rate.FeeForKWeight(NewKWeightUnit(payloadKWU))) + + // Convert to SatPerWeight. + // Rate: 1 sat / 12,000 wu = 0.0000833... sat/wu. + // Rounds to 0.000. + w := rate.ToSatPerWeight() + require.Equal(t, "0.000 sat/wu", w.String()) + require.Equal(t, expectedFee, + w.FeeForWeight(NewWeightUnit(payloadWU))) + + // Convert to SatPerVByte. + // Rate: 1 sat / 3,000 vb = 0.00033... sat/vb. + // Rounds to 0.000. + vb := rate.ToSatPerVByte() + require.Equal(t, "0.000 sat/vb", vb.String()) + require.Equal(t, expectedFee, + vb.FeeForVByte(NewVByte(payloadVB))) + + // Convert to SatPerKVByte. + // Rate: 1 sat / 3 kvb = 0.333... sat/kvb. + kvb := rate.ToSatPerKVByte() + require.Equal(t, "0.333 sat/kvb", kvb.String()) + require.Equal(t, expectedFee, + kvb.FeeForKVByte(NewKVByte(payloadKVB))) + }) + + // 3. Test SatPerVByte. + // Rate: 1 sat / 3,000 vb = 0.00033... sat/vb. + t.Run("SatPerVByte", func(t *testing.T) { + t.Parallel() + + rate := CalcSatPerVByte(1, NewVByte(payloadVB)) + // Rounds to 0.000 at 3 decimals. + require.Equal(t, "0.000 sat/vb", rate.String()) + require.Equal(t, expectedFee, + rate.FeeForVByte(NewVByte(payloadVB))) + + // Convert to SatPerKVByte. + // Rate: 1 sat / 3 kvb = 0.333... sat/kvb. + kvb := rate.ToSatPerKVByte() + require.Equal(t, "0.333 sat/kvb", kvb.String()) + require.Equal(t, expectedFee, + kvb.FeeForKVByte(NewKVByte(payloadKVB))) + + // Convert to SatPerKWeight. + // Rate: 1 sat / 12 kwu = 0.0833... sat/kw. + kw := rate.ToSatPerKWeight() + require.Equal(t, "0.083 sat/kw", kw.String()) + require.Equal(t, expectedFee, + kw.FeeForKWeight(NewKWeightUnit(payloadKWU))) + + // Convert to SatPerWeight. + // Rate: 1 sat / 12,000 wu = 0.0000833... sat/wu. + // Rounds to 0.000. + w := rate.ToSatPerWeight() + require.Equal(t, "0.000 sat/wu", w.String()) + require.Equal(t, expectedFee, + w.FeeForWeight(NewWeightUnit(payloadWU))) + }) + + // 4. Test SatPerKVByte. + // Rate: 1 sat / 3 kvb = 0.333... sat/kvb. + t.Run("SatPerKVByte", func(t *testing.T) { + t.Parallel() + + rate := CalcSatPerKVByte(1, NewKVByte(payloadKVB)) + require.Equal(t, "0.333 sat/kvb", rate.String()) + require.Equal(t, expectedFee, + rate.FeeForKVByte(NewKVByte(payloadKVB))) + + // Convert to SatPerVByte. + // Rate: 1 sat / 3,000 vb = 0.00033... sat/vb. + // Rounds to 0.000. + vb := rate.ToSatPerVByte() + require.Equal(t, "0.000 sat/vb", vb.String()) + require.Equal(t, expectedFee, + vb.FeeForVByte(NewVByte(payloadVB))) + + // Convert to SatPerKWeight. + // Rate: 1 sat / 12 kwu = 0.0833... sat/kw. + kw := rate.ToSatPerKWeight() + require.Equal(t, "0.083 sat/kw", kw.String()) + require.Equal(t, expectedFee, + kw.FeeForKWeight(NewKWeightUnit(payloadKWU))) + + // Convert to SatPerWeight. + // Rate: 1 sat / 12,000 wu = 0.0000833... sat/wu. + // Rounds to 0.000. + w := rate.ToSatPerWeight() + require.Equal(t, "0.000 sat/wu", w.String()) + require.Equal(t, expectedFee, + w.FeeForWeight(NewWeightUnit(payloadWU))) + }) +} diff --git a/pkg/btcunit/txsize.go b/pkg/btcunit/txsize.go new file mode 100644 index 0000000000..bc61cbe57f --- /dev/null +++ b/pkg/btcunit/txsize.go @@ -0,0 +1,112 @@ +package btcunit + +import ( + "fmt" + + "github.com/btcsuite/btcd/blockchain" +) + +// baseUnit stores the canonical representation of a transaction size, which is +// weight units (wu). All other size units are derived from this. +type baseUnit struct { + wu uint64 +} + +// ToWU converts the unit to a WeightUnit. +func (b baseUnit) ToWU() WeightUnit { + return WeightUnit{b} +} + +// ToVB converts the unit to a VByte. +func (b baseUnit) ToVB() VByte { + return VByte{b} +} + +// ToKVB converts the unit to a KVByte. +func (b baseUnit) ToKVB() KVByte { + return KVByte{b} +} + +// ToKWU converts the unit to a KWeightUnit. +func (b baseUnit) ToKWU() KWeightUnit { + return KWeightUnit{b} +} + +// WeightUnit defines a unit to express the transaction size. One weight unit +// is 1/4_000_000 of the max block size. The tx weight is calculated using +// `Base tx size * 3 + Total tx size`. +// - Base tx size is size of the transaction serialized without the witness +// data. +// - Total tx size is the transaction size in bytes serialized according +// #BIP144. +type WeightUnit struct { + // The internal size is recorded in weight units. + baseUnit +} + +// NewWeightUnit creates a new WeightUnit from a uint64 value. +func NewWeightUnit(val uint64) WeightUnit { + return WeightUnit{baseUnit{wu: val}} +} + +// String returns the string representation of the weight unit. +func (w WeightUnit) String() string { + return fmt.Sprintf("%d wu", w.wu) +} + +// VByte defines a unit to express the transaction size. One virtual byte is +// 1/4th of a weight unit. The tx virtual bytes is calculated using `TxWeight / +// 4`. +type VByte struct { + // The internal size is recorded in weight units. + baseUnit +} + +// NewVByte creates a new VByte from a uint64 value. +func NewVByte(val uint64) VByte { + return VByte{baseUnit{wu: val * blockchain.WitnessScaleFactor}} +} + +// String returns the string representation of the virtual byte. +func (v VByte) String() string { + vbytes := (v.wu + blockchain.WitnessScaleFactor - 1) / + blockchain.WitnessScaleFactor + + return fmt.Sprintf("%d vb", vbytes) +} + +// KVByte defines a unit to express the transaction size in kilo-virtual-bytes. +type KVByte struct { + // The internal size is recorded in weight units. + baseUnit +} + +// NewKVByte creates a new KVByte from a uint64. +func NewKVByte(val uint64) KVByte { + return KVByte{baseUnit{wu: val * kilo * blockchain.WitnessScaleFactor}} +} + +// String returns the string representation of the kilo-virtual-byte. +func (k KVByte) String() string { + vbytes := (k.wu + blockchain.WitnessScaleFactor - 1) / + blockchain.WitnessScaleFactor + + return fmt.Sprintf("%d kvb", vbytes/kilo) +} + +// KWeightUnit defines a unit to express the transaction size in +// kilo-weight-units. +type KWeightUnit struct { + // The internal size is recorded in weight units. + baseUnit +} + +// NewKWeightUnit creates a new KWeightUnit from a uint64. +func NewKWeightUnit(val uint64) KWeightUnit { + return KWeightUnit{baseUnit{wu: val * kilo}} +} + +// String returns the string representation of the kilo-weight-unit. +func (k KWeightUnit) String() string { + return fmt.Sprintf("%d kwu", k.wu/kilo) +} diff --git a/pkg/btcunit/txsize_test.go b/pkg/btcunit/txsize_test.go new file mode 100644 index 0000000000..63835dcdcb --- /dev/null +++ b/pkg/btcunit/txsize_test.go @@ -0,0 +1,129 @@ +package btcunit + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestBaseUnitConversions checks that the conversion methods of baseUnit are +// correct. +func TestBaseUnitConversions(t *testing.T) { + t.Parallel() + + // Test data: 1000 weight units. + base := baseUnit{wu: 1000} + + // Test ToWU: 1000 wu. + wu := base.ToWU() + require.Equal(t, uint64(1000), wu.wu) + + // Test ToVByte: 1000 wu (250 vb). + vb := base.ToVB() + require.Equal(t, uint64(1000), vb.wu) + + // Test ToKVByte: 1000 wu (0.25 kvb). + kvb := base.ToKVB() + require.Equal(t, uint64(1000), kvb.wu) + + // Test ToKWeightUnit: 1000 wu (1 kwu). + kwu := base.ToKWU() + require.Equal(t, uint64(1000), kwu.wu) +} + +// TestTxSizeConversion checks that the conversion between weight units and +// virtual bytes is correct. +func TestTxSizeConversion(t *testing.T) { + t.Parallel() + + // We'll use 4000 weight units (wu) as our base for testing. This is + // equivalent to 1000 virtual bytes (vb), 1 kilo-virtual-byte (kvb), + // and 4 kilo-weight-units (kwu). + // + // Initialize the same size in different units. + wu := NewWeightUnit(4000) + vb := NewVByte(1000) + kvb := NewKVByte(1) + kwu := NewKWeightUnit(4) + + // Check that the internal 'wu' values are consistent across different + // unit types representing the same size. + require.Equal(t, uint64(4000), wu.wu) + require.Equal(t, uint64(4000), vb.wu) + require.Equal(t, uint64(4000), kvb.wu) + require.Equal(t, uint64(4000), kwu.wu) + + // Test conversions from WeightUnit. After conversion, the underlying + // weight units (wu) should remain 4000. + require.Equal(t, uint64(4000), wu.ToWU().wu) + require.Equal(t, uint64(4000), wu.ToVB().wu) + require.Equal(t, uint64(4000), wu.ToKVB().wu) + require.Equal(t, uint64(4000), wu.ToKWU().wu) + require.Equal(t, "4000 wu", wu.String()) + + // Test conversions from VByte. After conversion, the underlying weight + // units (wu) should remain 4000. + require.Equal(t, uint64(4000), vb.ToWU().wu) + require.Equal(t, uint64(4000), vb.ToVB().wu) + require.Equal(t, uint64(4000), vb.ToKVB().wu) + require.Equal(t, uint64(4000), vb.ToKWU().wu) + require.Equal(t, "1000 vb", vb.String()) + + // Test conversions from KVByte. After conversion, the underlying + // weight units (wu) should remain 4000. + require.Equal(t, uint64(4000), kvb.ToWU().wu) + require.Equal(t, uint64(4000), kvb.ToVB().wu) + require.Equal(t, uint64(4000), kvb.ToKVB().wu) + require.Equal(t, uint64(4000), kvb.ToKWU().wu) + require.Equal(t, "1 kvb", kvb.String()) + + // Test conversions from KWeightUnit. After conversion, the underlying + // weight units (wu) should remain 4000. + require.Equal(t, uint64(4000), kwu.ToWU().wu) + require.Equal(t, uint64(4000), kwu.ToVB().wu) + require.Equal(t, uint64(4000), kwu.ToKVB().wu) + require.Equal(t, uint64(4000), kwu.ToKWU().wu) + require.Equal(t, "4 kwu", kwu.String()) +} + +// TestTxSizePrecision checks that precision is preserved when converting +// between units for values that are not perfectly divisible by the witness +// scale factor. +func TestTxSizePrecision(t *testing.T) { + t.Parallel() + + // Use a weight unit value that is not divisible by 4 + // (WitnessScaleFactor). + // 3999 % 4 = 3. + wu := NewWeightUnit(3999) + + // Convert to VByte. This should wrap the same underlying wu value. + vb := wu.ToVB() + require.Equal(t, uint64(3999), vb.wu) + + // Convert back to WeightUnit. Should still be 3999. + wu2 := vb.ToWU() + require.Equal(t, uint64(3999), wu2.wu) + + // The string representation should still perform the rounding for + // display. + // ceil(3999 / 4) = 1000. + require.Equal(t, "1000 vb", vb.String()) +} + +// TestTxSizeStringer tests the stringer methods of the tx size types. +func TestTxSizeStringer(t *testing.T) { + t.Parallel() + + // Create a test weight of 1000 wu. + wu := NewWeightUnit(1000) + vb := NewVByte(250) + kvb := NewKVByte(1) + kwu := NewKWeightUnit(1) + + // Test String. + require.Equal(t, "1000 wu", wu.String()) + require.Equal(t, "250 vb", vb.String()) + require.Equal(t, "1 kvb", kvb.String()) + require.Equal(t, "1 kwu", kwu.String()) +} diff --git a/rpc/legacyrpc/methods.go b/rpc/legacyrpc/methods.go index 9747fe6854..5c1652557b 100644 --- a/rpc/legacyrpc/methods.go +++ b/rpc/legacyrpc/methods.go @@ -450,7 +450,7 @@ func getBalance(icmd interface{}, w *wallet.Wallet) (interface{}, error) { // getBestBlock handles a getbestblock request by returning a JSON object // with the height and hash of the most recently processed block. func getBestBlock(icmd interface{}, w *wallet.Wallet) (interface{}, error) { - blk := w.Manager.SyncedTo() + blk := w.AddrManager().SyncedTo() result := &btcjson.GetBestBlockResult{ Hash: blk.Hash.String(), Height: blk.Height, @@ -461,14 +461,14 @@ func getBestBlock(icmd interface{}, w *wallet.Wallet) (interface{}, error) { // getBestBlockHash handles a getbestblockhash request by returning the hash // of the most recently processed block. func getBestBlockHash(icmd interface{}, w *wallet.Wallet) (interface{}, error) { - blk := w.Manager.SyncedTo() + blk := w.AddrManager().SyncedTo() return blk.Hash.String(), nil } // getBlockCount handles a getblockcount request by returning the chain height // of the most recently processed block. func getBlockCount(icmd interface{}, w *wallet.Wallet) (interface{}, error) { - blk := w.Manager.SyncedTo() + blk := w.AddrManager().SyncedTo() return blk.Height, nil } @@ -670,7 +670,8 @@ func renameAccount(icmd interface{}, w *wallet.Wallet) (interface{}, error) { if err != nil { return nil, err } - return nil, w.RenameAccount(waddrmgr.KeyScopeBIP0044, account, cmd.NewAccount) + + return nil, w.RenameAccountDeprecated(waddrmgr.KeyScopeBIP0044, account, cmd.NewAccount) } // getNewAddress handles a getnewaddress request by returning a new @@ -701,7 +702,7 @@ func getNewAddress(icmd interface{}, w *wallet.Wallet) (interface{}, error) { if err != nil { return nil, err } - addr, err := w.NewAddress(account, keyScope) + addr, err := w.NewAddressDeprecated(account, keyScope) if err != nil { return nil, err } @@ -811,7 +812,7 @@ func getTransaction(icmd interface{}, w *wallet.Wallet) (interface{}, error) { return nil, &ErrNoTransactionInfo } - syncBlock := w.Manager.SyncedTo() + syncBlock := w.AddrManager().SyncedTo() // TODO: The serialized transaction is already in the DB, so // reserializing can be avoided here. @@ -1134,7 +1135,7 @@ func listReceivedByAddress(icmd interface{}, w *wallet.Wallet) (interface{}, err account string } - syncBlock := w.Manager.SyncedTo() + syncBlock := w.AddrManager().SyncedTo() // Intermediate data for all addresses. allAddrData := make(map[string]AddrData) @@ -1213,7 +1214,7 @@ func listReceivedByAddress(icmd interface{}, w *wallet.Wallet) (interface{}, err func listSinceBlock(icmd interface{}, w *wallet.Wallet, chainClient *chain.RPCClient) (interface{}, error) { cmd := icmd.(*btcjson.ListSinceBlockCmd) - syncBlock := w.Manager.SyncedTo() + syncBlock := w.AddrManager().SyncedTo() targetConf := int64(*cmd.TargetConfirmations) // For the result we need the block hash for the last block counted @@ -1330,7 +1331,7 @@ func listUnspent(icmd interface{}, w *wallet.Wallet) (interface{}, error) { } } - return w.ListUnspent(int32(*cmd.MinConf), int32(*cmd.MaxConf), "") + return w.ListUnspentDeprecated(int32(*cmd.MinConf), int32(*cmd.MaxConf), "") //nolint:gosec,staticcheck } // lockUnspent handles the lockunspent command. @@ -1777,7 +1778,7 @@ func validateAddress(icmd interface{}, w *wallet.Wallet) (interface{}, error) { result.Address = addr.EncodeAddress() result.IsValid = true - ainfo, err := w.AddressInfo(addr) + ainfo, err := w.AddressInfoDeprecated(addr) if err != nil { if waddrmgr.IsError(err, waddrmgr.ErrAddressNotFound) { // No additional information available about the address. @@ -1896,7 +1897,7 @@ func walletIsLocked(icmd interface{}, w *wallet.Wallet) (interface{}, error) { // wallets, returning an error if any wallet is not encrypted (for example, // a watching-only wallet). func walletLock(icmd interface{}, w *wallet.Wallet) (interface{}, error) { - w.Lock() + w.LockDeprecated() return nil, nil } @@ -1911,7 +1912,7 @@ func walletPassphrase(icmd interface{}, w *wallet.Wallet) (interface{}, error) { if timeout != 0 { unlockAfter = time.After(timeout) } - err := w.Unlock([]byte(cmd.Passphrase), unlockAfter) + err := w.UnlockDeprecated([]byte(cmd.Passphrase), unlockAfter) return nil, err } diff --git a/rpc/legacyrpc/server.go b/rpc/legacyrpc/server.go index 63c87bcdc5..fe7f43a7e2 100644 --- a/rpc/legacyrpc/server.go +++ b/rpc/legacyrpc/server.go @@ -219,7 +219,7 @@ func (s *Server) Stop() { chainClient := s.chainClient s.handlerMu.Unlock() if wallet != nil { - wallet.Stop() + wallet.StopDeprecated() } if chainClient != nil { chainClient.Stop() diff --git a/rpc/rpcserver/server.go b/rpc/rpcserver/server.go index 3e10db11a9..7309574532 100644 --- a/rpc/rpcserver/server.go +++ b/rpc/rpcserver/server.go @@ -188,7 +188,10 @@ func (s *walletServer) Accounts(ctx context.Context, req *pb.AccountsRequest) ( func (s *walletServer) RenameAccount(ctx context.Context, req *pb.RenameAccountRequest) ( *pb.RenameAccountResponse, error) { - err := s.wallet.RenameAccount(waddrmgr.KeyScopeBIP0044, req.AccountNumber, req.NewName) + err := s.wallet.RenameAccountDeprecated( + waddrmgr.KeyScopeBIP0044, req.GetAccountNumber(), + req.GetNewName(), + ) if err != nil { return nil, translateError(err) } @@ -209,7 +212,11 @@ func (s *walletServer) NextAccount(ctx context.Context, req *pb.NextAccountReque defer func() { lock <- time.Time{} // send matters, not the value }() - err := s.wallet.Unlock(req.Passphrase, lock) + + //nolint:staticcheck // This should be fixed once the interface + // refactor is finished, and new wallet RPC is built. + err := s.wallet.UnlockDeprecated(req.GetPassphrase(), + lock) if err != nil { return nil, translateError(err) } @@ -231,7 +238,9 @@ func (s *walletServer) NextAddress(ctx context.Context, req *pb.NextAddressReque ) switch req.Kind { case pb.NextAddressRequest_BIP0044_EXTERNAL: - addr, err = s.wallet.NewAddress(req.Account, waddrmgr.KeyScopeBIP0044) + addr, err = s.wallet.NewAddressDeprecated( + req.GetAccount(), waddrmgr.KeyScopeBIP0044, + ) case pb.NextAddressRequest_BIP0044_INTERNAL: addr, err = s.wallet.NewChangeAddress(req.Account, waddrmgr.KeyScopeBIP0044) default: @@ -259,7 +268,11 @@ func (s *walletServer) ImportPrivateKey(ctx context.Context, req *pb.ImportPriva defer func() { lock <- time.Time{} // send matters, not the value }() - err = s.wallet.Unlock(req.Passphrase, lock) + + //nolint:staticcheck // This should be fixed once the interface + // refactor is finished, and new wallet RPC is built. + err = s.wallet.UnlockDeprecated(req.GetPassphrase(), + lock) if err != nil { return nil, translateError(err) } @@ -455,7 +468,11 @@ func (s *walletServer) SignTransaction(ctx context.Context, req *pb.SignTransact defer func() { lock <- time.Time{} // send matters, not the value }() - err = s.wallet.Unlock(req.Passphrase, lock) + + //nolint:staticcheck // This should be fixed once the interface + // refactor is finished, and new wallet RPC is built. + err = s.wallet.UnlockDeprecated(req.GetPassphrase(), + lock) if err != nil { return nil, translateError(err) } diff --git a/scripts/filter_coverage.sh b/scripts/filter_coverage.sh new file mode 100755 index 0000000000..2696c04b00 --- /dev/null +++ b/scripts/filter_coverage.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +# Filter coverage files to exclude opposite backend implementations +# Usage: filter_coverage.sh +# Where db_type is 'sqlite' or 'postgres' + +set -e + +DB_TYPE="$1" +if [ "$DB_TYPE" != "sqlite" ] && [ "$DB_TYPE" != "postgres" ]; then + echo "Usage: $0 " + exit 1 +fi + +COVERAGE_FILE="coverage-itest-${DB_TYPE}.txt" +if [ ! -f "$COVERAGE_FILE" ]; then + echo "Coverage file $COVERAGE_FILE not found" + exit 1 +fi + +# Create filtered version +FILTERED_FILE="${COVERAGE_FILE}.filtered" + +# Keep the mode line, filter out opposite backend files +head -1 "$COVERAGE_FILE" >"$FILTERED_FILE" + +if [ "$DB_TYPE" = "sqlite" ]; then + # For sqlite: exclude postgres files + tail -n +2 "$COVERAGE_FILE" | grep -Ev 'pg|postgres' >>"$FILTERED_FILE" +else + # For postgres: exclude sqlite files + tail -n +2 "$COVERAGE_FILE" | grep -Ev 'sqlite' >>"$FILTERED_FILE" +fi + +# Replace original with filtered +mv "$FILTERED_FILE" "$COVERAGE_FILE" + +# Output the filtered coverage percentage +go tool cover -func="$COVERAGE_FILE" | + awk '/^total:/ { print "Filtered test coverage for '"$DB_TYPE"': " $3 }' diff --git a/scripts/tidy_modules.sh b/scripts/tidy_modules.sh index 3fa5bfb252..4dc7e0d738 100755 --- a/scripts/tidy_modules.sh +++ b/scripts/tidy_modules.sh @@ -1,17 +1,58 @@ #!/bin/bash -SUBMODULES=$(find . -mindepth 2 -name "go.mod" | cut -d'/' -f2) +# Keep unset-variable and pipeline safety, but do NOT use `set -e` here. +# +# We want two properties at the same time: +# 1. `go mod tidy` should run for the root module and every nested module, so we +# still auto-fix any module that *can* be tidied in the current run. +# 2. A failure in one module must still make the overall script fail so CI does +# not silently pass over a broken submodule. +# +# Using `set -e` would stop on the first failing module and skip the remaining +# tidies. Instead, we run each module explicitly, collect failures, and exit +# non-zero at the end if any module failed. +set -uo pipefail +# Collect module directories whose `go mod tidy` invocation failed. +failures=() -# Run 'go mod tidy' for root. -go mod tidy +run_tidy() { + local module_dir="$1" -# Run 'go mod tidy' for each module. -for submodule in $SUBMODULES -do - pushd $submodule + # Run each tidy in the target module directory so the command behaves as if a + # developer had entered that module and run `go mod tidy` manually. + echo "Running 'go mod tidy' in ${module_dir}" - go mod tidy + if ! ( + # Turn off any parent go.work file. We want to validate each module as an + # independent module, because CI and release consumers will resolve module + # dependencies that way. + cd "$module_dir" + GOWORK=off go mod tidy + ); then + # Keep going so other modules still get tidied, but remember the failure so + # the script can fail loudly once every module has been attempted. + failures+=("$module_dir") + fi +} - popd -done +# Tidy the repository root module first. +run_tidy "." + +# Tidy every actual nested Go module. +# +# We intentionally discover module directories from real `go.mod` paths instead +# of truncating the path (for example, `wallet/txauthor`, `wallet/txrules`, and +# `wallet/txsizes` are distinct modules and must be tidied separately). +while IFS= read -r submodule; do + run_tidy "$submodule" +done < <(find . -mindepth 2 -name "go.mod" -exec dirname {} \; | sort -u) + +# Fail at the end if any module failed to tidy. This preserves the previous +# “tidy as much as possible” behavior while making module errors visible to CI. +if [ ${#failures[@]} -ne 0 ]; then + echo + echo "go mod tidy failed for the following modules:" + printf ' - %s\n' "${failures[@]}" + exit 1 +fi diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000000..21de01f0d0 --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,45 @@ +# sqlc configuration file for generating type-safe Go code from SQL. +# For full documentation, see: https://docs.sqlc.dev/en/stable/reference/config.html +version: "2" +sql: + - engine: "postgresql" + schema: "wallet/internal/sql/pg/migrations" + queries: "wallet/internal/sql/pg/queries" + gen: + go: + out: "wallet/internal/sql/pg/sqlc" + package: "sqlc" + + # This is the driver package that sqlc will use in the generated code. + sql_package: database/sql + + # Generate a `Querier` interface of all query methods. It's useful for + # mocking in tests. + emit_interface: true + + # Export generated SQL statements so they're usable from other packages. + emit_exported_queries: true + + # Generate prepared statements for better performance with database/sql. + emit_prepared_queries: true + + - engine: "sqlite" + schema: "wallet/internal/sql/sqlite/migrations" + queries: "wallet/internal/sql/sqlite/queries" + gen: + go: + out: "wallet/internal/sql/sqlite/sqlc" + package: "sqlc" + + # This is the driver package that sqlc will use in the generated code. + sql_package: database/sql + + # Generate a `Querier` interface of all query methods. It's useful for + # mocking in tests. + emit_interface: true + + # Export generated SQL statements so they're usable from other packages. + emit_exported_queries: true + + # Generate prepared statements for better performance with database/sql. + emit_prepared_queries: true diff --git a/tools/Dockerfile b/tools/Dockerfile index 03f84094a6..fa2d491e86 100644 --- a/tools/Dockerfile +++ b/tools/Dockerfile @@ -2,15 +2,17 @@ FROM golang:1.24.6-alpine ENV GOFLAGS="-buildvcs=false" -RUN apk update && apk add --no-cache git +RUN apk update && apk add --no-cache git bash WORKDIR /build COPY tools/go.mod tools/go.sum ./ RUN go install -trimpath \ - github.com/golangci/golangci-lint/v2/cmd/golangci-lint \ - github.com/rinchsan/gosimports/cmd/gosimports \ + github.com/golangci/golangci-lint/v2/cmd/golangci-lint \ + github.com/rinchsan/gosimports/cmd/gosimports \ + github.com/sqlc-dev/sqlc/cmd/sqlc \ + github.com/yoheimuta/protolint/cmd/protolint \ && rm -rf /go/pkg/mod \ && rm -rf /root/.cache/go-build \ && rm -rf /go/src \ diff --git a/tools/go.mod b/tools/go.mod index 71dde0a57e..c5d295402a 100644 --- a/tools/go.mod +++ b/tools/go.mod @@ -5,14 +5,18 @@ go 1.24.6 tool ( github.com/golangci/golangci-lint/v2/cmd/golangci-lint github.com/rinchsan/gosimports/cmd/gosimports + github.com/sqlc-dev/sqlc/cmd/sqlc + github.com/yoheimuta/protolint/cmd/protolint ) require ( 4d63.com/gocheckcompilerdirectives v1.3.0 // indirect 4d63.com/gochecknoglobals v0.2.2 // indirect + cel.dev/expr v0.24.0 // indirect codeberg.org/chavacava/garif v0.2.0 // indirect dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect dev.gaijin.team/go/golib v0.6.0 // indirect + filippo.io/edwards25519 v1.1.0 // indirect github.com/4meepo/tagalign v1.4.3 // indirect github.com/Abirdcfly/dupword v0.1.6 // indirect github.com/AlwxSin/noinlineerr v1.0.5 // indirect @@ -30,6 +34,7 @@ require ( github.com/alfatraining/structtag v1.0.0 // indirect github.com/alingse/asasalint v0.0.11 // indirect github.com/alingse/nilnesserr v0.2.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/ashanbrown/forbidigo/v2 v2.1.0 // indirect github.com/ashanbrown/makezero/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect @@ -51,21 +56,26 @@ require ( github.com/charmbracelet/x/ansi v0.8.0 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/chavacava/garif v0.1.0 // indirect github.com/ckaznocha/intrange v0.3.1 // indirect + github.com/cubicdaiya/gonp v1.0.4 // indirect github.com/curioswitch/go-reassign v0.3.0 // indirect github.com/daixiang0/gci v0.13.7 // indirect github.com/dave/dst v0.27.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/denis-tingaikin/go-header v0.5.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/ettle/strcase v0.2.0 // indirect github.com/fatih/color v1.18.0 // indirect github.com/fatih/structtag v1.2.0 // indirect github.com/firefart/nonamedreturns v1.0.6 // indirect github.com/fsnotify/fsnotify v1.5.4 // indirect github.com/fzipp/gocyclo v0.6.0 // indirect + github.com/gertd/go-pluralize v0.2.1 // indirect github.com/ghostiam/protogetter v0.3.15 // indirect github.com/go-critic/go-critic v0.13.0 // indirect + github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/go-toolsmith/astcast v1.1.0 // indirect github.com/go-toolsmith/astcopy v1.1.0 // indirect github.com/go-toolsmith/astequal v1.2.0 // indirect @@ -77,7 +87,7 @@ require ( github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/gofrs/flock v0.12.1 // indirect - github.com/golang/protobuf v1.5.3 // indirect + github.com/golang/protobuf v1.5.4 // indirect github.com/golangci/asciicheck v0.5.0 // indirect github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect github.com/golangci/go-printf-func-name v0.1.0 // indirect @@ -89,20 +99,30 @@ require ( github.com/golangci/revgrep v0.8.0 // indirect github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect + github.com/google/cel-go v0.26.1 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/gordonklaus/ineffassign v0.1.0 // indirect github.com/gostaticanalysis/analysisutil v0.7.1 // indirect github.com/gostaticanalysis/comment v1.5.0 // indirect github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect github.com/gostaticanalysis/nilerr v0.1.1 // indirect + github.com/hashicorp/go-hclog v1.6.3 // indirect github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect + github.com/hashicorp/go-plugin v1.6.3 // indirect github.com/hashicorp/go-version v1.7.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hashicorp/hcl v1.0.0 // indirect + github.com/hashicorp/yamux v0.1.1 // indirect github.com/hexops/gotextdiff v1.0.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.5 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jgautheron/goconst v1.8.2 // indirect github.com/jingyugao/rowserrcheck v1.1.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect github.com/jjti/go-spancheck v0.6.5 // indirect github.com/julz/importas v0.2.0 // indirect github.com/karamaru-alpha/copyloopvar v1.2.1 // indirect @@ -135,11 +155,18 @@ require ( github.com/moricho/tparallel v0.3.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/nakabonne/nestif v0.3.1 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect github.com/nishanths/exhaustive v0.12.0 // indirect github.com/nishanths/predeclared v0.2.2 // indirect github.com/nunnatsa/ginkgolinter v0.20.0 // indirect + github.com/oklog/run v1.0.0 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pganalyze/pg_query_go/v6 v6.1.0 // indirect + github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb // indirect + github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 // indirect + github.com/pingcap/log v1.1.0 // indirect + github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/polyfloyd/go-errorlint v1.8.0 // indirect github.com/prometheus/client_golang v1.12.1 // indirect @@ -152,8 +179,10 @@ require ( github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect github.com/raeperd/recvcheck v0.2.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rinchsan/gosimports v0.3.8 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/riza-io/grpc-go v0.2.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/ryancurrah/gomodguard v1.4.1 // indirect github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect @@ -172,12 +201,15 @@ require ( github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.7 // indirect github.com/spf13/viper v1.12.0 // indirect + github.com/sqlc-dev/sqlc v1.30.0 // indirect github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect github.com/stbenjam/no-sprintf-host-port v0.2.0 // indirect + github.com/stoewer/go-strcase v1.2.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/testify v1.10.0 // indirect github.com/subosito/gotenv v1.4.1 // indirect github.com/tetafro/godot v1.5.1 // indirect + github.com/tetratelabs/wazero v1.9.0 // indirect github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 // indirect github.com/timonwong/loggercheck v0.11.0 // indirect github.com/tomarrell/wrapcheck/v2 v2.11.0 // indirect @@ -186,30 +218,46 @@ require ( github.com/ultraware/whitespace v0.2.0 // indirect github.com/uudashr/gocognit v1.2.0 // indirect github.com/uudashr/iface v1.4.1 // indirect + github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 // indirect + github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 // indirect github.com/xen0n/gosmopolitan v1.3.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yagipy/maintidx v1.0.0 // indirect github.com/yeya24/promlinter v0.3.0 // indirect github.com/ykadowak/zerologlint v0.1.5 // indirect + github.com/yoheimuta/go-protoparser/v4 v4.14.2 // indirect + github.com/yoheimuta/protolint v0.56.4 // indirect gitlab.com/bosi/decorder v0.4.2 // indirect go-simpler.org/musttag v0.14.0 // indirect go-simpler.org/sloglint v0.11.1 // indirect go.augendre.info/arangolint v0.2.0 // indirect go.augendre.info/fatcontext v0.8.1 // indirect + go.uber.org/atomic v1.11.0 // indirect go.uber.org/automaxprocs v1.6.0 // indirect - go.uber.org/multierr v1.10.0 // indirect + go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect + golang.org/x/crypto v0.41.0 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/exp/typeparams v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/mod v0.27.0 // indirect + golang.org/x/net v0.43.0 // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.28.0 // indirect golang.org/x/tools v0.36.0 // indirect - google.golang.org/protobuf v1.36.6 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20251014184007-4626949a642f // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f // indirect + google.golang.org/grpc v1.75.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect honnef.co/go/tools v0.6.1 // indirect + modernc.org/libc v1.66.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.38.2 // indirect mvdan.cc/gofumpt v0.8.0 // indirect mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4 // indirect ) diff --git a/tools/go.sum b/tools/go.sum index 58b944c06d..17c6b6fd96 100644 --- a/tools/go.sum +++ b/tools/go.sum @@ -2,6 +2,8 @@ 4d63.com/gocheckcompilerdirectives v1.3.0/go.mod h1:ofsJ4zx2QAuIP/NO/NAh1ig6R1Fb18/GI7RVMwz7kAY= 4d63.com/gochecknoglobals v0.2.2 h1:H1vdnwnMaZdQW/N+NrkT1SZMTBmcwHe9Vq8lJcYYTtU= 4d63.com/gochecknoglobals v0.2.2/go.mod h1:lLxwTQjL5eIesRbvnzIP3jZtG140FnTdz+AlMa+ogt0= +cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= @@ -41,6 +43,8 @@ dev.gaijin.team/go/exhaustruct/v4 v4.0.0/go.mod h1:aZ/k2o4Y05aMJtiux15x8iXaumE88 dev.gaijin.team/go/golib v0.6.0 h1:v6nnznFTs4bppib/NyU1PQxobwDHwCXXl15P7DV5Zgo= dev.gaijin.team/go/golib v0.6.0/go.mod h1:uY1mShx8Z/aNHWDyAkZTkX+uCi5PdX7KsG1eDQa2AVE= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/4meepo/tagalign v1.4.3 h1:Bnu7jGWwbfpAie2vyl63Zup5KuRv21olsPIha53BJr8= github.com/4meepo/tagalign v1.4.3/go.mod h1:00WwRjiuSbrRJnSVeGWPLp2epS5Q/l4UEy0apLLS37c= github.com/Abirdcfly/dupword v0.1.6 h1:qeL6u0442RPRe3mcaLcbaCi2/Y/hOcdtw6DE9odjz9c= @@ -86,12 +90,15 @@ github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQ github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I= github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEWd/w= github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/ashanbrown/forbidigo/v2 v2.1.0 h1:NAxZrWqNUQiDz19FKScQ/xvwzmij6BiOw3S0+QUQ+Hs= github.com/ashanbrown/forbidigo/v2 v2.1.0/go.mod h1:0zZfdNAuZIL7rSComLGthgc/9/n2FqspBOH90xlCHdA= github.com/ashanbrown/makezero/v2 v2.0.1 h1:r8GtKetWOgoJ4sLyUx97UTwyt2dO7WkGFHizn/Lo8TY= github.com/ashanbrown/makezero/v2 v2.0.1/go.mod h1:kKU4IMxmYW1M4fiEHMb2vc5SFoPzXvgbMR9gIp5pjSw= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -108,6 +115,8 @@ github.com/breml/bidichk v0.3.3 h1:WSM67ztRusf1sMoqH6/c4OBCUlRVTKq+CbSeo0R17sE= github.com/breml/bidichk v0.3.3/go.mod h1:ISbsut8OnjB367j5NseXEGGgO/th206dVa427kR8YTE= github.com/breml/errchkjson v0.4.1 h1:keFSS8D7A2T0haP9kzZTi7o26r7kE3vymjZNeNDRDwg= github.com/breml/errchkjson v0.4.1/go.mod h1:a23OvR6Qvcl7DG/Z4o0el6BRAjKnaReoPQFciAl9U3s= +github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= +github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= github.com/butuzov/ireturn v0.4.0 h1:+s76bF/PfeKEdbG8b54aCocxXmi0wvYdOVsWxVO7n8E= github.com/butuzov/ireturn v0.4.0/go.mod h1:ghI0FrCmap8pDWZwfPisFD1vEc56VKH4NpQUxDHta70= github.com/butuzov/mirror v1.3.0 h1:HdWCXzmwlQHdVhwvsfBb2Au0r3HyINry3bDWLYXiKoc= @@ -133,6 +142,8 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0G github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/chavacava/garif v0.1.0 h1:2JHa3hbYf5D9dsgseMKAmc/MZ109otzgNFk5s87H9Pc= +github.com/chavacava/garif v0.1.0/go.mod h1:XMyYCkEL58DF0oyW4qDjjnPWONs2HBqYKI+UIPD+Gww= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -141,6 +152,8 @@ github.com/ckaznocha/intrange v0.3.1/go.mod h1:QVepyz1AkUoFQkpEqksSYpNpUo3c5W7nW github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cubicdaiya/gonp v1.0.4 h1:ky2uIAJh81WiLcGKBVD5R7KsM/36W6IqqTy6Bo6rGws= +github.com/cubicdaiya/gonp v1.0.4/go.mod h1:iWGuP/7+JVTn02OWhRemVbMmG1DOUnmrGTYYACpOI0I= github.com/curioswitch/go-reassign v0.3.0 h1:dh3kpQHuADL3cobV/sSGETA8DOv457dwl+fbBAhrQPs= github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88= github.com/daixiang0/gci v0.13.7 h1:+0bG5eK9vlI08J+J/NWGbWPTNiXPG4WhNLJOkSxWITQ= @@ -156,12 +169,15 @@ github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42 github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q= github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= @@ -174,6 +190,8 @@ github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwV github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo= github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA= +github.com/gertd/go-pluralize v0.2.1 h1:M3uASbVjMnTsPb0PNqg+E/24Vwigyo/tvyMTtAlLgiA= +github.com/gertd/go-pluralize v0.2.1/go.mod h1:rbYaKDbsXxmRfr8uygAEKhOWsjyrrqrkHVpZvoOp8zk= github.com/ghostiam/protogetter v0.3.15 h1:1KF5sXel0HE48zh1/vn0Loiw25A9ApyseLzQuif1mLY= github.com/ghostiam/protogetter v0.3.15/go.mod h1:WZ0nw9pfzsgxuRsPOFQomgDVSWtDLJRfQJEhsGbmQMA= github.com/go-critic/go-critic v0.13.0 h1:kJzM7wzltQasSUXtYyTl6UaPVySO6GkaR1thFnJ6afY= @@ -189,8 +207,12 @@ github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= @@ -249,8 +271,8 @@ github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golangci/asciicheck v0.5.0 h1:jczN/BorERZwK8oiFBOGvlGPknhvq0bjnysTj4nUfo0= github.com/golangci/asciicheck v0.5.0/go.mod h1:5RMNAInbNFw2krqN6ibBxN/zfRFa9S6tA1nPdM0l8qQ= github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 h1:WUvBfQL6EW/40l6OmeSBYQJNSif4O11+bmWEz+C7FYw= @@ -275,6 +297,8 @@ github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e h1:gD6P7NEo7Eqt github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e/go.mod h1:h+wZwLjUTJnm/P2rwlbJdRPZXOzaT36/FwnPnY2inzc= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ= +github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -301,6 +325,8 @@ github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a h1://KbezygeMJZCSHH+HgUZiTeSoiuFspbMg1ge+eFj18= github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/gordonklaus/ineffassign v0.1.0 h1:y2Gd/9I7MdY1oEIt+n+rowjBNDcLQq3RsH5hwJd0f9s= @@ -318,8 +344,12 @@ github.com/gostaticanalysis/nilerr v0.1.1/go.mod h1:wZYb6YI5YAxxq0i1+VJbY0s2YONW github.com/gostaticanalysis/testutil v0.3.1-0.20210208050101-bfb5c8eec0e4/go.mod h1:D+FIZ+7OahH3ePw/izIEeH5I06eKs1IKI4Xr64/Am3M= github.com/gostaticanalysis/testutil v0.5.0 h1:Dq4wT1DdTwTGCQQv3rl3IvD5Ld0E6HiY+3Zh0sUGqw8= github.com/gostaticanalysis/testutil v0.5.0/go.mod h1:OLQSbuM6zw2EvCcXTz1lVq5unyoNft372msDY0nY5Hs= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-immutable-radix/v2 v2.1.0 h1:CUW5RYIcysz+D3B+l1mDeXrQ7fUvGGCwJfdASSzbrfo= github.com/hashicorp/go-immutable-radix/v2 v2.1.0/go.mod h1:hgdqLXA4f6NIjRVisM1TJ9aOJVNRqKZj+xDGF6m7PBw= +github.com/hashicorp/go-plugin v1.6.3 h1:xgHB+ZUSYeuJi96WtxEjzi23uh7YQpznjGh0U0UUrwg= +github.com/hashicorp/go-plugin v1.6.3/go.mod h1:MRobyh+Wc/nYy1V4KAXUiYfzxoYhs7V1mlH1Z7iY2h0= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= @@ -331,15 +361,29 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= +github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= +github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jgautheron/goconst v1.8.2 h1:y0XF7X8CikZ93fSNT6WBTb/NElBu9IjaY7CCYQrCMX4= github.com/jgautheron/goconst v1.8.2/go.mod h1:A0oxgBCHy55NQn6sYpO7UdnA9p+h7cPtoOZUmvNIako= +github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= +github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= github.com/jingyugao/rowserrcheck v1.1.1 h1:zibz55j/MJtLsjP1OF4bSdgXxwL1b+Vn7Tjzq7gFzUs= github.com/jingyugao/rowserrcheck v1.1.1/go.mod h1:4yvlZSDb3IyDTUZJUmpZfm2Hwok+Dtp+nu2qOq+er9c= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jjti/go-spancheck v0.6.5 h1:lmi7pKxa37oKYIMScialXUK6hP3iY5F1gu+mLBPgYB8= github.com/jjti/go-spancheck v0.6.5/go.mod h1:aEogkeatBrbYsyW6y5TgDfihCulDYciL1B7rG2vSsrU= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= @@ -406,8 +450,12 @@ github.com/matoous/godox v1.1.0 h1:W5mqwbyWrwZv6OQ5Z1a/DHGMOvXYCBP3+Ht7KMoJhq4= github.com/matoous/godox v1.1.0/go.mod h1:jgE/3fUXiTurkdHOLT5WEkThTSuE7yxHv5iWPa80afs= github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= @@ -433,12 +481,16 @@ github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRW github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U= github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhKRf3Swg= github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs= github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk= github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c= github.com/nunnatsa/ginkgolinter v0.20.0 h1:OmWLkAFO2HUTYcU6mprnKud1Ey5pVdiVNYGO5HVicx8= github.com/nunnatsa/ginkgolinter v0.20.0/go.mod h1:dCIuFlTPfQerXgGUju3VygfAFPdC5aE1mdacCDKDJcQ= +github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= +github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= github.com/onsi/gomega v1.38.0 h1:c/WX+w8SLAinvuKKQFh77WEucCnPk4j2OTUr7lt7BeY= @@ -454,6 +506,17 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pganalyze/pg_query_go/v6 v6.1.0 h1:jG5ZLhcVgL1FAw4C/0VNQaVmX1SUJx71wBGdtTtBvls= +github.com/pganalyze/pg_query_go/v6 v6.1.0/go.mod h1:nvTHIuoud6e1SfrUaFwHqT0i4b5Nr+1rPWVds3B5+50= +github.com/pingcap/errors v0.11.0/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= +github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb h1:3pSi4EDG6hg0orE1ndHkXvX6Qdq2cZn8gAPir8ymKZk= +github.com/pingcap/errors v0.11.5-0.20240311024730-e056997136bb/go.mod h1:X2r9ueLEUZgtx2cIogM0v4Zj5uvvzhuuiu7Pn8HzMPg= +github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 h1:tdMsjOqUR7YXHoBitzdebTvOjs/swniBTOLy5XiMtuE= +github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86/go.mod h1:exzhVYca3WRtd6gclGNErRWb1qEgff3LYta0LvRmON4= +github.com/pingcap/log v1.1.0 h1:ELiPxACz7vdo1qAvvaWJg1NrYFoY6gqAh/+Uo6aXdD8= +github.com/pingcap/log v1.1.0/go.mod h1:DWQW5jICDR7UJh4HtxXSM20Churx4CQL0fwL/SoOSA4= +github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0 h1:W3rpAI3bubR6VWOcwxDIG0Gz9G5rl5b3SL116T0vBt0= +github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0/go.mod h1:+8feuexTKcXHZF/dkDfvCwEyBAmgb4paFc3/WeYV2eE= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -497,11 +560,15 @@ github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 h1:M8mH9eK4OUR4l github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567/go.mod h1:DWNGW8A4Y+GyBgPuaQJuWiy0XYftx4Xm/y5Jqk9I6VQ= github.com/raeperd/recvcheck v0.2.0 h1:GnU+NsbiCqdC2XX5+vMZzP+jAJC5fht7rcVTAhX74UI= github.com/raeperd/recvcheck v0.2.0/go.mod h1:n04eYkwIR0JbgD73wT8wL4JjPC3wm0nFtzBnWNocnYU= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rinchsan/gosimports v0.3.8 h1:X4Pb9yFf6teHvogorT04yK/0W2Df7eHO79biCcYrA4c= github.com/rinchsan/gosimports v0.3.8/go.mod h1:t0567k69sUHjLvJMPDsV31THZC+8UIbY1oL7NW+0I2c= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/riza-io/grpc-go v0.2.0 h1:2HxQKFVE7VuYstcJ8zqpN84VnAoJ4dCL6YFhJewNcHQ= +github.com/riza-io/grpc-go v0.2.0/go.mod h1:2bDvR9KkKC3KhtlSHfR3dAXjUMT86kg4UfWFyVGWqi8= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= @@ -549,18 +616,29 @@ github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.12.0 h1:CZ7eSOd3kZoaYDLbXnmzgQI5RlciuXBMA+18HwHRfZQ= github.com/spf13/viper v1.12.0/go.mod h1:b6COn30jlNxbm/V2IqWiNWkJ+vZNiMNksliPCiuKtSI= +github.com/sqlc-dev/sqlc v1.30.0 h1:H4HrNwPc0hntxGWzAbhlfplPRN4bQpXFx+CaEMcKz6c= +github.com/sqlc-dev/sqlc v1.30.0/go.mod h1:QnEN+npugyhUg1A+1kkYM3jc2OMOFsNlZ1eh8mdhad0= github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YEwQ0= github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I= github.com/stbenjam/no-sprintf-host-port v0.2.0 h1:i8pxvGrt1+4G0czLr/WnmyH7zbZ8Bg8etvARQ1rpyl4= github.com/stbenjam/no-sprintf-host-port v0.2.0/go.mod h1:eL0bQ9PasS0hsyTyfTjjG+E80QIyPnBVQbYZyv20Jfk= +github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= +github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs= @@ -571,6 +649,8 @@ github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpR github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY= github.com/tetafro/godot v1.5.1 h1:PZnjCol4+FqaEzvZg5+O8IY2P3hfY9JzRBNPv1pEDS4= github.com/tetafro/godot v1.5.1/go.mod h1:cCdPtEndkmqqrhiCfkmxDodMQJ/f3L1BCNskCUZdTwk= +github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= +github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 h1:9LPGD+jzxMlnk5r6+hJnar67cgpDIz/iyD+rfl5r2Vk= github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67/go.mod h1:mkjARE7Yr8qU23YcGMSALbIxTQ9r9QBVahQOBRfU460= github.com/timonwong/loggercheck v0.11.0 h1:jdaMpYBl+Uq9mWPXv1r8jc5fC3gyXx4/WGwTnnNKn4M= @@ -587,6 +667,10 @@ github.com/uudashr/gocognit v1.2.0 h1:3BU9aMr1xbhPlvJLSydKwdLN3tEUUrzPSSM8S4hDYR github.com/uudashr/gocognit v1.2.0/go.mod h1:k/DdKPI6XBZO1q7HgoV2juESI2/Ofj9AcHPZhBBdrTU= github.com/uudashr/iface v1.4.1 h1:J16Xl1wyNX9ofhpHmQ9h9gk5rnv2A6lX/2+APLTo0zU= github.com/uudashr/iface v1.4.1/go.mod h1:pbeBPlbuU2qkNDn0mmfrxP2X+wjPMIQAy+r1MBXSXtg= +github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 h1:mJdDDPblDfPe7z7go8Dvv1AJQDI3eQ/5xith3q2mFlo= +github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07/go.mod h1:Ak17IJ037caFp4jpCw/iQQ7/W74Sqpb1YuKJU6HTKfM= +github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 h1:OvLBa8SqJnZ6P+mjlzc2K7PM22rRUPE1x32G9DTPrC4= +github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY= github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pHqQM= github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= @@ -597,6 +681,10 @@ github.com/yeya24/promlinter v0.3.0 h1:JVDbMp08lVCP7Y6NP3qHroGAO6z2yGKQtS5Jsjqto github.com/yeya24/promlinter v0.3.0/go.mod h1:cDfJQQYv9uYciW60QT0eeHlFodotkYZlL+YcPQN+mW4= github.com/ykadowak/zerologlint v0.1.5 h1:Gy/fMz1dFQN9JZTPjv1hxEk+sRWm05row04Yoolgdiw= github.com/ykadowak/zerologlint v0.1.5/go.mod h1:KaUskqF3e/v59oPmdq1U1DnKcuHokl2/K1U4pmIELKg= +github.com/yoheimuta/go-protoparser/v4 v4.14.2 h1:/P/LlX1CF9NaTWEltGcIZVvNlPbhABuAnBtAWpb3+74= +github.com/yoheimuta/go-protoparser/v4 v4.14.2/go.mod h1:AHNNnSWnb0UoL4QgHPiOAg2BniQceFscPI5X/BZNHl8= +github.com/yoheimuta/protolint v0.56.4 h1:FWvXjVNRaKJWJFxsnilRZhfQ4tc3KS8VVGWecxnLXLo= +github.com/yoheimuta/protolint v0.56.4/go.mod h1:XrnOc0O5mckLR1GAOjqMPdb3R3ZEfLkMpLoq5RxxoG0= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -621,12 +709,33 @@ go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= +go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= -go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -638,6 +747,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -648,8 +759,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= -golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/exp/typeparams v0.0.0-20250620022241-b7579e27df2b h1:KdrhdYPDUvJTvrDK9gdjfFd6JTk8vA1WJoldYSi0kHo= @@ -763,6 +874,7 @@ golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -785,10 +897,13 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -835,6 +950,8 @@ golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgw golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= @@ -884,6 +1001,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= @@ -935,6 +1054,10 @@ google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7Fc google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto/googleapis/api v0.0.0-20251014184007-4626949a642f h1:OiFuztEyBivVKDvguQJYWq1yDcfAHIID/FVrPR4oiI0= +google.golang.org/genproto/googleapis/api v0.0.0-20251014184007-4626949a642f/go.mod h1:kprOiu9Tr0JYyD6DORrc4Hfyk3RFXqkQ3ctHEum3ZbM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f h1:1FTH6cpXFsENbPR5Bu8NQddPSaUUE6NA2XdZdDSAJK4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -947,6 +1070,8 @@ google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKa google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= +google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -959,8 +1084,9 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -970,14 +1096,19 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= @@ -989,6 +1120,32 @@ honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9 honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI= honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4= +modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= +modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= +modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= +modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM= +modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= +modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= +modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= mvdan.cc/gofumpt v0.8.0 h1:nZUCeC2ViFaerTcYKstMmfysj6uhQrA2vJe+2vwGU6k= mvdan.cc/gofumpt v0.8.0/go.mod h1:vEYnSzyGPmjvFkqJWtXkh79UwPWP9/HMxQdGEXZHjpg= mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4 h1:WjUu4yQoT5BHT1w8Zu56SP8367OuBV5jvo+4Ulppyf8= diff --git a/waddrmgr/addr_type.go b/waddrmgr/addr_type.go new file mode 100644 index 0000000000..04cf2cde9c --- /dev/null +++ b/waddrmgr/addr_type.go @@ -0,0 +1,454 @@ +package waddrmgr + +import ( + "errors" + "fmt" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/txscript" +) + +const ( + // p2pkhPkScriptSize is the fixed scriptPubKey size for P2PKH outputs. + // 25 bytes = OP_DUP (1) + OP_HASH160 (1) + OP_DATA_20 (1) + + // (20) + OP_EQUALVERIFY (1) + OP_CHECKSIG (1). + p2pkhPkScriptSize = 25 + + // p2shPkScriptSize is the fixed scriptPubKey size for P2SH outputs. + // 23 bytes = OP_HASH160 (1) + OP_DATA_20 (1) +