From 7c778c0164c007d256d77ff20ba2e90fae41f40e Mon Sep 17 00:00:00 2001 From: Joshua Sing Date: Mon, 26 Feb 2024 22:28:13 +1100 Subject: [PATCH] Initial public commit Co-authored-by: Marco Peereboom Co-authored-by: ClaytonNorthey92 Co-authored-by: Joel Sing Co-authored-by: Joshua Sing Co-authored-by: Maxwell Sanchez Co-authored-by: John C. Vernaleo --- .dockerignore | 5 + .github/ISSUE_TEMPLATE/bug_report.yml | 49 + .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature_request.yml | 39 + .github/PULL_REQUEST_TEMPLATE.md | 6 + .github/release.yml | 28 + .github/workflows/go.yml | 70 + .github/workflows/network-test.yml | 39 + .github/workflows/release.yml | 220 ++ .gitignore | 5 + LICENSE | 21 + Makefile | 95 + README.md | 141 + api/api.go | 47 + api/auth/auth.go | 78 + api/auth/auth_test.go | 89 + api/auth/secp256k1.go | 241 ++ api/auth/secp256k1_test.go | 179 + api/bfgapi/bfgapi.go | 257 ++ api/bssapi/bssapi.go | 166 + api/protocol/protocol.go | 549 +++ bitcoin/bitcoin.go | 164 + bitcoin/bitcoin_test.go | 257 ++ cmd/bfgd/bfgd.go | 150 + cmd/bssd/bssd.go | 125 + cmd/extool/extool.go | 144 + cmd/hemictl/README.md | 59 + cmd/hemictl/hemictl.go | 454 +++ cmd/keygen/keygen.go | 111 + cmd/popmd/popmd.go | 129 + config/config.go | 149 + config/config_test.go | 81 + database/bfgd/TESTS.md | 11 + database/bfgd/database.go | 142 + database/bfgd/database_ext_test.go | 1803 ++++++++++ database/bfgd/postgres/postgres.go | 1059 ++++++ database/bfgd/scripts/0001.sql | 64 + database/bfgd/scripts/0002.sql | 48 + database/bfgd/scripts/0003.sql | 10 + database/bfgd/scripts/0004.sql | 34 + database/bfgd/scripts/0005.sql | 29 + database/bfgd/scripts/0006.sql | 8 + database/bfgd/scripts/createdb.sh | 10 + database/bfgd/scripts/db.sh | 16 + database/bfgd/scripts/dropdb.sh | 10 + database/bfgd/scripts/populatedb.sh | 10 + database/bfgd/scripts/upgradedb.sh | 10 + database/database.go | 356 ++ database/database_test.go | 300 ++ database/postgres/postgres.go | 283 ++ database/scripts/db.sh | 82 + docker/bfgd/Dockerfile | 63 + docker/bssd/Dockerfile | 59 + docker/popmd/Dockerfile | 60 + e2e/docker-compose.yml | 120 + e2e/e2e_ext_test.go | 3688 ++++++++++++++++++++ e2e/mocktimism/Dockerfile | 9 + e2e/mocktimism/mocktimism.go | 124 + e2e/network_test.go | 565 +++ e2e/postgres.Dockerfile | 3 + ethereum/ethereum.go | 21 + go.mod | 81 + go.sum | 329 ++ hemi/electrumx/electrumx.go | 358 ++ hemi/hemi.go | 251 ++ hemi/hemi_test.go | 33 + hemi/pop/pop.go | 172 + hemi/pop/pop_test.go | 197 ++ service/bfg/bfg.go | 1492 ++++++++ service/bfg/bfg_test.go | 172 + service/bss/bss.go | 846 +++++ service/bss/bss_test.go | 150 + service/deucalion/deucalion.go | 199 ++ service/popm/popm.go | 787 +++++ service/popm/popm_test.go | 846 +++++ service/popm/prometheus.go | 43 + service/popm/prometheus_wasm.go | 13 + version/version.go | 89 + version/version_buildinfo.go | 32 + version/version_nobuildinfo.go | 11 + web/.gitignore | 1 + web/Makefile | 27 + web/integrationtest/integrationtest.go | 40 + web/popminer/popminer.go | 512 +++ web/www/index.html | 91 + web/www/index.js | 180 + web/www/popminer.js | 22 + 87 files changed, 20123 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/release.yml create mode 100644 .github/workflows/go.yml create mode 100644 .github/workflows/network-test.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 api/api.go create mode 100644 api/auth/auth.go create mode 100644 api/auth/auth_test.go create mode 100644 api/auth/secp256k1.go create mode 100644 api/auth/secp256k1_test.go create mode 100644 api/bfgapi/bfgapi.go create mode 100644 api/bssapi/bssapi.go create mode 100644 api/protocol/protocol.go create mode 100644 bitcoin/bitcoin.go create mode 100644 bitcoin/bitcoin_test.go create mode 100644 cmd/bfgd/bfgd.go create mode 100644 cmd/bssd/bssd.go create mode 100644 cmd/extool/extool.go create mode 100644 cmd/hemictl/README.md create mode 100644 cmd/hemictl/hemictl.go create mode 100644 cmd/keygen/keygen.go create mode 100644 cmd/popmd/popmd.go create mode 100644 config/config.go create mode 100644 config/config_test.go create mode 100644 database/bfgd/TESTS.md create mode 100644 database/bfgd/database.go create mode 100644 database/bfgd/database_ext_test.go create mode 100644 database/bfgd/postgres/postgres.go create mode 100644 database/bfgd/scripts/0001.sql create mode 100644 database/bfgd/scripts/0002.sql create mode 100644 database/bfgd/scripts/0003.sql create mode 100644 database/bfgd/scripts/0004.sql create mode 100644 database/bfgd/scripts/0005.sql create mode 100644 database/bfgd/scripts/0006.sql create mode 100755 database/bfgd/scripts/createdb.sh create mode 100644 database/bfgd/scripts/db.sh create mode 100755 database/bfgd/scripts/dropdb.sh create mode 100755 database/bfgd/scripts/populatedb.sh create mode 100755 database/bfgd/scripts/upgradedb.sh create mode 100644 database/database.go create mode 100644 database/database_test.go create mode 100644 database/postgres/postgres.go create mode 100644 database/scripts/db.sh create mode 100644 docker/bfgd/Dockerfile create mode 100644 docker/bssd/Dockerfile create mode 100644 docker/popmd/Dockerfile create mode 100644 e2e/docker-compose.yml create mode 100644 e2e/e2e_ext_test.go create mode 100644 e2e/mocktimism/Dockerfile create mode 100644 e2e/mocktimism/mocktimism.go create mode 100644 e2e/network_test.go create mode 100644 e2e/postgres.Dockerfile create mode 100644 ethereum/ethereum.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 hemi/electrumx/electrumx.go create mode 100644 hemi/hemi.go create mode 100644 hemi/hemi_test.go create mode 100644 hemi/pop/pop.go create mode 100644 hemi/pop/pop_test.go create mode 100644 service/bfg/bfg.go create mode 100644 service/bfg/bfg_test.go create mode 100644 service/bss/bss.go create mode 100644 service/bss/bss_test.go create mode 100644 service/deucalion/deucalion.go create mode 100644 service/popm/popm.go create mode 100644 service/popm/popm_test.go create mode 100644 service/popm/prometheus.go create mode 100644 service/popm/prometheus_wasm.go create mode 100644 version/version.go create mode 100644 version/version_buildinfo.go create mode 100644 version/version_nobuildinfo.go create mode 100644 web/.gitignore create mode 100644 web/Makefile create mode 100644 web/integrationtest/integrationtest.go create mode 100644 web/popminer/popminer.go create mode 100644 web/www/index.html create mode 100644 web/www/index.js create mode 100644 web/www/popminer.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..379f38ba --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +/bin/ +/dist/ +/pkg/ +/.gocache/ +*.sw? diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..2da079aa --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,49 @@ +name: "Bug Report" +description: "Report a bug to help us improve." +labels: [ "status: triage", "type: bug" ] +body: + - type: markdown + attributes: + value: | + Please **do not** use this template for questions. + Have a question or need help? Join our [Discord server](https://discord.gg/8z9antYtus)! + + - type: checkboxes + attributes: + label: "Confirmation" + description: "Please confirm that you have done the following before creating this issue." + options: + - label: "I have checked for similar issues." + required: true + + - type: textarea + attributes: + label: "Describe the bug" + description: "Please provide a clear and concise description of what the issue is." + validations: + required: true + + - type: textarea + attributes: + label: "Expected behaviour" + description: "What should have happened?" + validations: + required: true + + - type: textarea + attributes: + label: "Environment" + description: | + Please provide information about your operating environment here. + value: | + Version: + Operating System: + Architecture: + + - type: textarea + attributes: + label: "Additional Information" + description: | + Please provide any additional information that could help with reproducing and fixing this bug. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..1ee8130b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: "Discord" + url: "https://discord.gg/8z9antYtus" + about: "Have a question or need help? Join our Discord server!" diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..cee4fb84 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,39 @@ +name: "Feature Request" +description: "Suggest a new feature or a change to existing functionality" +labels: [ "status: triage", "type: enhancement" ] +body: + - type: markdown + attributes: + value: | + Please **do not** use this template for questions. + Have a question or need help? Join our [Discord server](https://discord.gg/8z9antYtus)! + + - type: checkboxes + attributes: + label: "Confirmation" + description: "Please confirm that you have done the following before creating this issue." + options: + - label: "I have checked for similar issues." + required: true + + - type: textarea + attributes: + label: "Problem" + description: "Please provide a clear and concise description of what the problem is." + validations: + required: false + + - type: textarea + attributes: + label: "Suggested solution" + description: "How do you suggest we solve this problem?" + validations: + required: true + + - type: textarea + attributes: + label: "Additional Information" + description: | + Please provide any additional information that could help us understand the requested change. + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..1368bcc7 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,6 @@ +**Summary** + + + +**Changes** + diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 00000000..0610b2a1 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,28 @@ +changelog: + exclude: + labels: + - "type: dependencies" + - "area: ci" + categories: + - title: "โš ๏ธ Breaking changes" + labels: + - "status: breaking" + - title: "โœจ Additions" + labels: + - "type: feature" + - title: "๐Ÿ› Fixes" + labels: + - "type: bug" + - title: "๐Ÿ”ง Improvements" + labels: + - "type: enhancement" + - "type: refactor" + - title: "๐Ÿ“– Documentation" + labels: + - "type: documentation" + - title: "๐Ÿงช Test coverage" + labels: + - "type: test" + - title: "โš™๏ธ Other" + labels: + - "*" diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 00000000..f17c73ca --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,70 @@ +# Copyright (c) 2024 Hemi Labs, Inc. +# Use of this source code is governed by the MIT License, +# which can be found in the LICENSE file. + +# GitHub Actions workflow to lint, build and test. +name: "Go" +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + workflow_dispatch: + workflow_call: + +concurrency: + group: "go-${{ github.workflow }}-${{ github.event.number || github.ref }}" + cancel-in-progress: "${{ github.event.action == 'push' || github.event.action == 'pull_request' }}" + +permissions: + contents: read + +jobs: + build: + name: "Build" + runs-on: "ubuntu-latest" + strategy: + matrix: + go-version: [ "1.22.x" ] + services: + postgres: + image: postgres:16-alpine + ports: + - 5432:5432 + env: + POSTGRES_PASSWORD: "postgres" + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - name: "Checkout repository" + uses: actions/checkout@v4 + + - name: "Setup Go ${{ matrix.go-version }}" + uses: actions/setup-go@v5 + with: + go-version: "${{ matrix.go-version }}" + cache: true + check-latest: true + + - name: "Download and verify dependencies" + run: make deps + + - name: "make race" + continue-on-error: true + run: make race + + - name: "make" + continue-on-error: true + env: + PGTESTURI: "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable" + run: | + make + git diff --exit-code + + - name: "make web popm" + continue-on-error: true + run: | + cd web && make diff --git a/.github/workflows/network-test.yml b/.github/workflows/network-test.yml new file mode 100644 index 00000000..7a5d1794 --- /dev/null +++ b/.github/workflows/network-test.yml @@ -0,0 +1,39 @@ +# Copyright (c) 2024 Hemi Labs, Inc. +# Use of this source code is governed by the MIT License, +# which can be found in the LICENSE file. + +# GitHub Actions workflow to run e2e network tests. +name: "E2E Network Test" +on: + workflow_dispatch: # Manually triggered + +concurrency: + group: "${{ github.workflow }}-${{ github.event.number || github.ref }}" + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + name: "Test" + runs-on: "ubuntu-latest" + strategy: + matrix: + go-version: [ "1.22.x" ] + steps: + - name: "Checkout repository" + uses: actions/checkout@v4 + + - name: "Setup Go ${{ matrix.go-version }}" + uses: actions/setup-go@v5 + with: + go-version: "${{ matrix.go-version }}" + cache: true + check-latest: true + + - name: "Download and verify dependencies" + run: make deps + + - name: "make networktest" + run: make networktest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..43420def --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,220 @@ +# Copyright (c) 2024 Hemi Labs, Inc. +# Use of this source code is governed by the MIT License, +# which can be found in the LICENSE file. + +# GitHub Actions workflow to create releases. +# Releases are published from tags matching "v*.*.*". +name: "Release" +on: + push: + tags: [ "v*.*.*" ] + workflow_dispatch: + inputs: + version: + description: "Version" + type: string + required: true + docker: + description: "Build Docker images" + type: boolean + required: true + default: false + release: + description: "Create GitHub release and publish Docker images" + type: boolean + required: true + default: false + +concurrency: + group: "release-${{ github.ref }}" + cancel-in-progress: true + +env: + GO_VERSION: "1.22.x" + +jobs: + # Run tests + test: + name: "Test" + uses: ./.github/workflows/go.yml + + # Prepare to release + prepare: + name: "Prepare" + runs-on: "ubuntu-latest" + permissions: + contents: read + outputs: + version: "${{ steps.version.outputs.version }}" + tag: "${{ steps.version.outputs.tag }}" + version_type: "${{ steps.version.outputs.type }}" + steps: + - name: "Determine version type" + id: version + env: + RAW_VERSION: "${{ inputs.version || github.ref_name }}" + # This script determines the version type (stability), e.g. + # 1.0.0 = stable, 1.1.0-rc.1 = unstable, 0.1.0 = unstable + run: | + VERSION=$(echo "$RAW_VERSION" | sed -e 's/^v//') + TAG=$(echo "$RAW_VERSION" | sed -E 's/^([^v])/v\1/g') + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + TYPE=unstable + if echo "VERSION" | grep -Eq '^[1-9][0-9]*\.[0-9]+\.[0-9]+$'; then + TYPE=stable + fi + echo "Detected that $TAG is $TYPE" + echo "type=$TYPE" >> "$GITHUB_OUTPUT" + + # Build binaries + build: + name: "Build (${{ matrix.goos }}/${{ matrix.goarch }})" + runs-on: "ubuntu-latest" + needs: [ "test", "prepare" ] + permissions: + contents: read + strategy: + fail-fast: true + matrix: + goos: [ "linux", "darwin" ] + goarch: [ "amd64", "arm64" ] + include: + - goos: "windows" + goarch: "amd64" + - goos: "openbsd" + goarch: "amd64" + steps: + - name: "Checkout repository" + uses: actions/checkout@v4 + + - name: "Setup Go ${{ env.GO_VERSION }}" + uses: actions/setup-go@v5 + with: + go-version: "${{ env.GO_VERSION }}" + cache: true + check-latest: true + + - name: "Download and verify dependencies" + run: make deps + + - name: "Create binary archive for ${{ matrix.goos }}/${{ matrix.goarch }}" + env: + GOOS: "${{ matrix.goos }}" + GOARCH: "${{ matrix.goarch }}" + CGO_ENABLED: 0 # Disable CGO. + GOGC: off # Disable GC during build, faster but uses more RAM. + run: make archive + + - name: "Upload artifacts" + uses: actions/upload-artifact@v4 + with: + name: "${{ matrix.goos }}_${{ matrix.goarch }}" + retention-days: 1 + path: | + dist/* + + # Build and publish Docker images + docker: + name: "Docker (${{ matrix.service }})" + runs-on: "ubuntu-latest" + if: github.event_name == 'push' || inputs.docker + needs: [ "test", "prepare" ] + permissions: + contents: read + packages: write + strategy: + fail-fast: true + matrix: + include: + - service: "bfgd" + platforms: "linux/amd64,linux/arm64" + - service: "bssd" + platforms: "linux/amd64,linux/arm64" + - service: "popmd" + platforms: "linux/amd64,linux/arm64" + steps: + - name: "Checkout repository" + uses: actions/checkout@v4 + + - name: "Setup QEMU" + uses: docker/setup-qemu-action@v3 + + - name: "Setup Docker Buildx" + uses: docker/setup-buildx-action@v3 + + - name: "Login to DockerHub" + if: github.event_name == 'push' || inputs.release + uses: docker/login-action@v3 + with: + username: "${{ secrets.DOCKERHUB_USERNAME }}" + password: "${{ secrets.DOCKERHUB_PASSWORD }}" + + - name: "Login to GitHub Container Registry" + if: github.event_name == 'push' || inputs.release + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: "${{ github.repository_owner }}" + password: "${{ secrets.GITHUB_TOKEN }}" + + - name: "Prepare" + id: "prepare" + run: | + echo "build_date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT" + + - name: "Build and push" + uses: docker/build-push-action@v5 + with: + context: "${{ github.workspace }}" + platforms: "${{ matrix.platforms }}" + file: "${{ github.workspace }}/docker/${{ matrix.service }}/Dockerfile" + push: "${{ github.event_name == 'push' || inputs.release }}" + build-args: | + VERSION=${{ needs.prepare.outputs.version }} + VCS_REF=${{ github.sha }} + BUILD_DATE=${{ steps.prepare.outputs.build_date }} + tags: | + hemilabs/${{ matrix.service }}:latest + hemilabs/${{ matrix.service }}:${{ needs.prepare.outputs.tag }} + ghcr.io/hemilabs/${{ matrix.service }}:latest + ghcr.io/hemilabs/${{ matrix.service }}:${{ needs.prepare.outputs.tag }} + + # Create GitHub Release + release: + name: "Release" + runs-on: "ubuntu-latest" + needs: [ "prepare", "build", "docker" ] + permissions: + # Permission to write contents is required to create GitHub releases. + # Builds are performed in a separate job with more restrictive permissions + # because this permission allows any action to write to the repository. + contents: write + steps: + - name: "Checkout repository" + uses: actions/checkout@v4 + + - name: "Create sources archive" + run: make sources + + - name: "Download build artifacts" + uses: actions/download-artifact@v4 + with: + pattern: "*_*" + path: "${{ github.workspace }}/dist/" + merge-multiple: true + + - name: "Create checksums" + run: make checksums + + - name: "Create GitHub release" + if: github.event_name == 'push' || inputs.release + env: + TAG: "${{ github.ref_name || needs.prepare.outputs.tag }}" + PRERELEASE: "${{ needs.prepare.outputs.version_type == 'unstable' }}" + GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + # Creates a GitHub release using the 'gh' CLI (https://github.com/cli/cli). + # Release notes will be generated by GitHub using the config at .github/release.yml. + run: | + gh release create "$TAG" ./dist/* --generate-notes --prerelease=$PRERELEASE diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..d1789222 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/bin/ +/dist/ +/pkg/ +.gocache +*.sw? diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..2114a1fb --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Hemi Labs, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..0c30bff6 --- /dev/null +++ b/Makefile @@ -0,0 +1,95 @@ +# Copyright (c) 2024 Hemi Labs, Inc. +# Use of this source code is governed by the MIT License, +# which can be found in the LICENSE file. + +# PROJECTPATH is the project root directory. +# This Makefile is stored in the project root directory, so the directory is +# retrieved by getting the directory of this Makefile. +PROJECTPATH = $(abspath $(dir $(realpath $(firstword $(MAKEFILE_LIST))))) + +export GOBIN=$(PROJECTPATH)/bin +export GOCACHE=$(PROJECTPATH)/.gocache +export GOPKG=$(PROJECTPATH)/pkg +DIST=$(PROJECTPATH)/dist + +ifeq ($(GOOS),windows) +BIN_EXT = .exe +endif + +project = heminetwork +version = $(shell git describe --tags 2>/dev/null || echo "v0.0.0") + +cmds = \ + bfgd \ + bssd \ + extool \ + keygen \ + popmd \ + hemictl + +.PHONY: all clean clean-dist deps $(cmds) build install lint lint-deps tidy race test vulncheck \ + vulncheck-deps dist archive sources checksums networktest + +all: lint tidy test build install + +clean: clean-dist + rm -rf $(GOBIN) $(GOCACHE) $(GOPKG) + +clean-dist: + rm -rf $(DIST) + +deps: lint-deps vulncheck-deps + go mod download + go mod verify + +$(cmds): + go build -trimpath -o $(GOBIN)/$@$(BIN_EXT) ./cmd/$@ + +build: + go build ./... + +install: $(cmds) + +lint: + $(shell go env GOPATH)/bin/goimports -w -l . + $(shell go env GOPATH)/bin/gofumpt -w -l . + go vet ./... + +lint-deps: + GOBIN=$(shell go env GOPATH)/bin go install golang.org/x/tools/cmd/goimports@latest + GOBIN=$(shell go env GOPATH)/bin go install mvdan.cc/gofumpt@latest + +tidy: + go mod tidy + +race: + go test -v -race ./... + +test: + go test -test.timeout=20m ./... + +vulncheck: + $(shell go env GOPATH)/bin/govulncheck ./... + +vulncheck-deps: + GOBIN=$(shell go env GOPATH)/bin go install golang.org/x/vuln/cmd/govulncheck@latest + +dist: + mkdir -p $(DIST) + +archive: dist install +ifeq ($(GOOS),windows) + cd $(GOBIN) && zip -r $(DIST)/$(project)_$(version)_$(GOOS)_$(GOARCH).zip *$(BIN_EXT) +else + cd $(GOBIN) && tar -czvf $(DIST)/$(project)_$(version)_$(GOOS)_$(GOARCH).tar.gz * +endif + +sources: dist + tar --exclude=dist --exclude=bin -czvf $(DIST)/$(project)_$(version)_sources.tar.gz * .gitignore .github + +checksums: dist + cd $(DIST) && shasum -a 256 * > $(project)_$(version)_checksums.txt + +networktest: + HEMI_RUN_NETWORK_TEST=1 \ + go test -count=1 -v -run TestFullNetwork -v ./e2e/network_test.go diff --git a/README.md b/README.md new file mode 100644 index 00000000..bd64bd18 --- /dev/null +++ b/README.md @@ -0,0 +1,141 @@ +# Hemi Network + +
+ Table of Contents + +* [What is the Hemi Network?](#what-is-the-hemi-network) + * [Services](#services) + * [License](#license) +* [Getting Started](#getting-started) + * [Building from Source](#building-from-source) + * [Downloading Binaries from Releases](#downloading-binaries-from-releases) + * [Running](#running) + * [Running popmd](#running-popmd) + * [CLI](#cli) + * [Web](#web) + * [Running bfgd](#running-bfgd) + * [Running bssd](#running-bssd) + +
+ +## What is the Hemi Network? + +Hemi is an EVM compatible L2 blockchain that brings Bitcoin security and Ethereum programability together. + +### Services + +Hemi Network consists of 3 services: + +* [PoP Miner (popmd)](service/popm): "mines" L2 Keystones into BTC blocks for proof-of-proof +* [Bitcoin Finality Governor (bfgd)](service/bfg): Hemi's gateway to the BTC network. +* [Bitcoin Secure Sequencer (bssd)](service/bss): Optimism's gateway to BFG, manages Hemi Network's consensus + +### License + +This project is licensed under the [MIT License](LICENSE). + +## Getting Started + +### Building from Source + +To build, you must have the following installed: + +* `git` +* `make` +* `go 1.21+` + +First, clone the repository: + +```shell +git clone https://github.com/hemilabs/heminetwork.git +``` + +Then build: + +```shell +make +``` + +This will put built binaries in `/bin/` + +### Downloading Binaries from Releases + +You can find releases on the [Releases Page](https://github.com/hemilabs/heminetwork/releases) + +### Running + +To view options for any of the services, you may run the following + +```shell +./bin/popmd --help +``` + +```shell +./bin/bfgd --help +``` + +```shell +./bin/bssd --help +``` + +### Running popmd + +popmd has a few crucial requirements to run: + +* a BTC private key that is funded, this can be a testnet address if you configure popmd as such +* a BFG URL to connect to + +if configured correctly and running, then popmd will start "mining" L2 Keystones by adding them to btc blocks that make +it into the chain + +#### CLI + +```shell +./bin/popmd +``` + +#### Web + +```shell +cd ./web +make +go run ./integrationtest +``` + +### Running bfgd + +bfgd has a few crucial requirements to run: + +* a postgres database, bfgd expects the sql scripts in `./database/bfgd/scripts/` to be run to set up your schema +* an electrumx node connected to the proper bitcoin network (testnet vs mainnet, etc.) + +### Running bssd + +bssd has a few crucial requirements to run: + +* a bfgd instance running to connect to + +### Running Network + +Prerequisites: `docker` + +To run the full network locally, you can run the following. Note that this will create +L2Keytones and BTC Blocks at a high rate. You can modify these in `./e2e/mocktimism/mocktimism.go` +or `./e2e/docker-compose.yml`. + +note: the `--build` flag is optional if you want to rebuild your code + +``` +docker-compose -f ./e2e/docker-compose.yml up --build +``` + +### Running the full network tests + +This runs a test with an entirely local heminet, it uses bitcoind in regtest +mode for the bitcoin chain + +Prerequisites: `docker` + +``` +make networktest +``` \ No newline at end of file diff --git a/api/api.go b/api/api.go new file mode 100644 index 00000000..fcacf075 --- /dev/null +++ b/api/api.go @@ -0,0 +1,47 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package api + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "strings" +) + +// hexDecode decodes a string that may be prefixed with " and/or 0x. Thus +// "0x00" and 0x00 or 00 are all valid hex encodings. If length is provided the +// decoded size must exactly match. The length parameter will be ignored if it +// is less than 0. +func hexDecode(data []byte, length int) ([]byte, error) { + x, _ := strings.CutPrefix(strings.Trim(string(data), "\""), "0x") + s, err := hex.DecodeString(x) + if err != nil { + return nil, err + } + if length >= 0 && length != len(s) { + return nil, fmt.Errorf("invalid length: %v != %v", length, len(s)) + } + return s, nil +} + +// ByteSlice is used to hex encode addresses in JSON structs. +type ByteSlice []byte + +func (bs ByteSlice) MarshalJSON() ([]byte, error) { + return json.Marshal(hex.EncodeToString(bs)) +} + +func (bs *ByteSlice) UnmarshalJSON(data []byte) error { + if string(data) == "null" || string(data) == `""` { + return nil + } + s, err := hexDecode(data, -1) + if err != nil { + return err + } + *bs = s + return nil +} diff --git a/api/auth/auth.go b/api/auth/auth.go new file mode 100644 index 00000000..1c092c52 --- /dev/null +++ b/api/auth/auth.go @@ -0,0 +1,78 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package auth + +import ( + "bytes" + "crypto/rand" + "crypto/sha256" + "fmt" + "io" + + "github.com/juju/loggo" +) + +const ( + daemonName = "auth" + defaultLogLevel = daemonName + "=INFO" + + nonceLength = 16 +) + +var ( + log = loggo.GetLogger(daemonName) + + zeroNonce = [nonceLength]byte{} +) + +// AuthenticateMessage +type AuthenticateMessage struct { + Nonce [nonceLength]byte // random nonce + Message string // human readable message +} + +func NewAuthenticateMessage(message string) (*AuthenticateMessage, error) { + am := &AuthenticateMessage{ + Message: message, + } + _, err := io.ReadFull(rand.Reader, am.Nonce[:]) + if err != nil { + return nil, fmt.Errorf("readfull: %w", err) + } + return am, nil +} + +func MustNewAuthenticateMessage(message string) *AuthenticateMessage { + am, err := NewAuthenticateMessage(message) + if err != nil { + panic(err) + } + return am +} + +func NewAuthenticateFromBytes(b []byte) (*AuthenticateMessage, error) { + if len(b) < nonceLength { + return nil, fmt.Errorf("authenicate message too short") + } + am := &AuthenticateMessage{} + copy(am.Nonce[0:], b[:nonceLength]) + am.Message = string(b[nonceLength:]) + if bytes.Equal(am.Nonce[:], zeroNonce[:]) { + return nil, fmt.Errorf("invalid nonce") + } + return am, nil +} + +func (am *AuthenticateMessage) Serialize() []byte { + b := make([]byte, nonceLength+len(am.Message)) + copy(b[:nonceLength], am.Nonce[:]) + copy(b[nonceLength:], []byte(am.Message)) + return b +} + +func (am *AuthenticateMessage) Hash() []byte { + hash := sha256.Sum256(am.Serialize()) + return hash[:] +} diff --git a/api/auth/auth_test.go b/api/auth/auth_test.go new file mode 100644 index 00000000..b22c479c --- /dev/null +++ b/api/auth/auth_test.go @@ -0,0 +1,89 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package auth + +import ( + "bytes" + "testing" +) + +func TestAuthenticateMessage(t *testing.T) { + tests := []struct { + name string + am *AuthenticateMessage + fuzzer func(*AuthenticateMessage) + want bool + }{ + { + name: "with text", + am: &AuthenticateMessage{ + Nonce: [nonceLength]byte{1, 2, 3, 4, 5, 6, 7, 8}, + Message: "Hello, World!", + }, + want: true, + }, + { + name: "with random nonce", + am: MustNewAuthenticateMessage("Hello, World!"), + want: true, + }, + { + name: "no text", + am: &AuthenticateMessage{ + Nonce: [nonceLength]byte{1, 2, 3, 4, 5, 6, 7, 8}, + Message: "", + }, + want: true, + }, + { + name: "nonce fuzzed", + am: &AuthenticateMessage{ + Nonce: [nonceLength]byte{1, 2, 3, 4, 5, 6, 7, 8}, + Message: "", + }, + fuzzer: func(am *AuthenticateMessage) { + am.Nonce[0]++ + }, + want: false, + }, + { + name: "message fuzzed", + am: &AuthenticateMessage{ + Nonce: [nonceLength]byte{1, 2, 3, 4, 5, 6, 7, 8}, + Message: "", + }, + fuzzer: func(am *AuthenticateMessage) { + am.Message = "hi" + }, + want: false, + }, + { + name: "message fuzzed 2", + am: &AuthenticateMessage{ + Nonce: [nonceLength]byte{1, 2, 3, 4, 5, 6, 7, 8}, + Message: "hI", + }, + fuzzer: func(am *AuthenticateMessage) { + am.Message = "hi" + }, + want: false, + }, + } + + for i, test := range tests { + am, err := NewAuthenticateFromBytes(test.am.Serialize()) + if err != nil { + t.Fatalf("test %v (%v) %v", i, test.name, err) + } + if test.fuzzer != nil { + test.fuzzer(test.am) + } + want := bytes.Equal(am.Hash(), test.am.Hash()) + if want != test.want { + t.Fatalf("test %v (%v) want != test.want (%v != %v)", + i, test.name, want, test.want) + } + } +} diff --git a/api/auth/secp256k1.go b/api/auth/secp256k1.go new file mode 100644 index 00000000..8c0e5cf7 --- /dev/null +++ b/api/auth/secp256k1.go @@ -0,0 +1,241 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package auth + +import ( + "context" + "encoding/hex" + "fmt" + "reflect" + + "github.com/davecgh/go-spew/spew" + dcrsecpk256k1 "github.com/decred/dcrd/dcrec/secp256k1/v4" + dcrecdsa "github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa" + + "github.com/hemilabs/heminetwork/api/protocol" +) + +const ( + CmdSecp256k1Error protocol.Command = "secp256k1-error" + CmdSecp256k1Hello = "secp256k1-hello" + CmdSecp256k1HelloChallenge = "secp256k1-hello-challenge" + CmdSecp256k1HelloChallengeAccepted = "secp256k1-hello-challenge-accepted" +) + +// Hello is a client->server command that sends the client ECDSA public key. +type Secp256k1Hello struct { + PublicKey string `json:"publickey"` // Client compressed public key +} + +// HelloChallenge is a server->client command that challenges the the client to +// sign the hash of the provided message. +type Secp256k1HelloChallenge struct { + Message string `json:"message"` +} + +// Secp256k1HelloChallengeAccepted returns the signature of the HelloChallenge.Message +// hash. +type Secp256k1HelloChallengeAccepted struct { + Signature string `json:"signature"` +} + +func handleSecp256k1Hello(message string, h *Secp256k1Hello) (*dcrsecpk256k1.PublicKey, *AuthenticateMessage, *Secp256k1HelloChallenge, error) { + log.Tracef("handleSecp256k1Hello") + defer log.Tracef("handleSecp256k1Hello exit") + + pkb, err := hex.DecodeString(h.PublicKey) + if err != nil { + return nil, nil, nil, fmt.Errorf("decode key: %w", err) + } + pubKey, err := dcrsecpk256k1.ParsePubKey(pkb) + if err != nil { + return nil, nil, nil, fmt.Errorf("parse public key: %w", err) + } + am, err := NewAuthenticateMessage(fmt.Sprintf("Hello: %x\nMessage: %v\n", + pubKey.SerializeCompressed(), message)) + if err != nil { + return nil, nil, nil, fmt.Errorf("new authenticate message: %w", err) + } + + hc := &Secp256k1HelloChallenge{ + Message: hex.EncodeToString(am.Serialize()), + } + return pubKey, am, hc, nil +} + +func handleSecp256k1HelloChallenge(privKey *dcrsecpk256k1.PrivateKey, hc *Secp256k1HelloChallenge) (*Secp256k1HelloChallengeAccepted, error) { + log.Tracef("handleSecp256k1HelloChallenge") + defer log.Tracef("handleSecp256k1HelloChallenge exit") + + message, err := hex.DecodeString(hc.Message) + if err != nil { + return nil, fmt.Errorf("hex decode: %w", err) + } + am, err := NewAuthenticateFromBytes(message) + if err != nil { + return nil, fmt.Errorf("new authenticator message: %w", err) + } + + signatureHash := am.Hash() + signature := dcrecdsa.SignCompact(privKey, signatureHash[:], true) + return &Secp256k1HelloChallengeAccepted{ + Signature: hex.EncodeToString(signature), + }, nil +} + +func handleSecp256k1HelloChallengeAccepted(am *AuthenticateMessage, hca *Secp256k1HelloChallengeAccepted) (*dcrsecpk256k1.PublicKey, error) { + log.Tracef("handleSecp256k1HelloChallengeAccepted") + defer log.Tracef("handleSecp256k1HelloChallengeAccepted exit") + + signature, err := hex.DecodeString(hca.Signature) + if err != nil { + return nil, fmt.Errorf("hex decode: %w", err) + } + signatureHash := am.Hash() + derived, _, err := dcrecdsa.RecoverCompact(signature, signatureHash[:]) + if err != nil { + return nil, fmt.Errorf("hex decode: %w", err) + } + return derived, nil +} + +type Secp256k1Auth struct { + privKey *dcrsecpk256k1.PrivateKey // client private key + pubKey *dcrsecpk256k1.PublicKey // client public key + + remotePubKey *dcrsecpk256k1.PublicKey // server side remote key (client) +} + +func NewSecp256k1AuthClient(privKey *dcrsecpk256k1.PrivateKey) (*Secp256k1Auth, error) { + return &Secp256k1Auth{privKey: privKey, pubKey: privKey.PubKey()}, nil +} + +func NewSecp256k1AuthServer() (*Secp256k1Auth, error) { + return &Secp256k1Auth{}, nil +} + +func (s Secp256k1Auth) RemotePublicKey() *dcrsecpk256k1.PublicKey { + pub := *s.remotePubKey + return &pub +} + +// Commands returns the protocol commands for this authenticator. +func (s *Secp256k1Auth) Commands() map[protocol.Command]reflect.Type { + return map[protocol.Command]reflect.Type{ + CmdSecp256k1Hello: reflect.TypeOf(Secp256k1Hello{}), + CmdSecp256k1HelloChallenge: reflect.TypeOf(Secp256k1HelloChallenge{}), + CmdSecp256k1HelloChallengeAccepted: reflect.TypeOf(Secp256k1HelloChallengeAccepted{}), + } +} + +func (s *Secp256k1Auth) HandshakeClient(ctx context.Context, conn protocol.APIConn) error { + log.Tracef("HandshakeClient") + defer log.Tracef("HandshakeClient exit") + + pubKey := hex.EncodeToString(s.pubKey.SerializeCompressed()) + id := "Hello: " + pubKey + err := protocol.Write(ctx, conn, s, id, Secp256k1Hello{PublicKey: pubKey}) + if err != nil { + return err + } + + state := 0 // Connection state machine + for { + _, _, payload, err := protocol.Read(ctx, conn, s) + if err != nil { + return fmt.Errorf("read: %v", err) + } + log.Tracef(spew.Sdump(payload)) + + switch c := payload.(type) { + case *Secp256k1HelloChallenge: + // Verify state + if state != 0 { + return fmt.Errorf("hello unexpected state: %v", state) + } + + hca, err := handleSecp256k1HelloChallenge(s.privKey, c) + if err != nil { + return fmt.Errorf("handleSecp256k1HelloChallenge: %v", err) + } + + requestID := "HelloChallengeAccepted:" + pubKey + err = protocol.Write(ctx, conn, s, requestID, hca) + if err != nil { + return fmt.Errorf("write HelloChallengeAccepted: %v", err) + } + + // Exit state machine + log.Tracef("HandshakeClient complete") + return nil + + default: + return fmt.Errorf("unexpected command: %T", payload) + } + } +} + +func (s *Secp256k1Auth) HandshakeServer(ctx context.Context, conn protocol.APIConn) error { + log.Tracef("HandshakeServer") + defer log.Tracef("HandshakeServer exit") + + var am *AuthenticateMessage + state := 0 // Connection state machine + for { + _, _, payload, err := protocol.Read(ctx, conn, s) + if err != nil { + return fmt.Errorf("read: %v", err) + } + log.Tracef(spew.Sdump(payload)) + + switch c := payload.(type) { + case *Secp256k1Hello: + // Verify state + if state != 0 { + return fmt.Errorf("hello unexpected state: %v", state) + } + var hc *Secp256k1HelloChallenge + s.pubKey, am, hc, err = handleSecp256k1Hello("I am not a robot!", c) + if err != nil { + return fmt.Errorf("could not create hello challenge: %v", + state) + } + + err = protocol.Write(ctx, conn, s, + "HelloChallenge:"+c.PublicKey, hc) + if err != nil { + return fmt.Errorf("write HelloChallenge: %v", err) + } + + case *Secp256k1HelloChallengeAccepted: + // Verify state + if state != 1 { + return fmt.Errorf("hello challenge accepted unexpected state: %v", state) + } + if am == nil { + return fmt.Errorf("hello challenge accepted message not set") + } + + derived, err := handleSecp256k1HelloChallengeAccepted(am, c) + if err != nil { + return fmt.Errorf("handleSecp256k1HelloChallengeAccepted: %v", err) + } + + // Exit state machine + if !derived.IsEqual(s.pubKey) { + return fmt.Errorf("handleSecp256k1HelloChallengeAccepted: not the same signer") + } + s.remotePubKey = derived + log.Tracef("HandshakeServer complete: %x", + derived.SerializeCompressed()) + return nil + + default: + return fmt.Errorf("unexpected command: %T", payload) + } + + state++ + } +} diff --git a/api/auth/secp256k1_test.go b/api/auth/secp256k1_test.go new file mode 100644 index 00000000..8ff48e50 --- /dev/null +++ b/api/auth/secp256k1_test.go @@ -0,0 +1,179 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package auth + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/davecgh/go-spew/spew" + dcrsecpk256k1 "github.com/decred/dcrd/dcrec/secp256k1/v4" + dcrecdsa "github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa" + "nhooyr.io/websocket" + + "github.com/hemilabs/heminetwork/api/bssapi" + "github.com/hemilabs/heminetwork/api/protocol" +) + +func TestHandshakeSignatureCrypto(t *testing.T) { + privKey, err := dcrsecpk256k1.GeneratePrivateKey() + if err != nil { + t.Fatal(err) + } + pubKey := privKey.PubKey() + + message := "Hello, World!" + sigHash := sha256.Sum256([]byte(message)) + sig := dcrecdsa.SignCompact(privKey, sigHash[:], true) + derived, _, err := dcrecdsa.RecoverCompact(sig, sigHash[:]) + if err != nil { + t.Fatal(err) + } + if !derived.IsEqual(pubKey) { + t.Fatal("not the same key") + } +} + +func TestHandshake(t *testing.T) { + // client generated private and public keys + privKey, err := dcrsecpk256k1.GeneratePrivateKey() + if err != nil { + t.Fatal(err) + } + pubKey := privKey.PubKey() + + // client sends hello + h := &Secp256k1Hello{ + PublicKey: hex.EncodeToString(pubKey.SerializeCompressed()), + } + clientPubKey, am, hc, err := handleSecp256k1Hello("I am not a robot!", h) + if err != nil { + t.Fatal(err) + } + + // client signs challenge from the server + hca, err := handleSecp256k1HelloChallenge(privKey, hc) + if err != nil { + t.Fatal(err) + } + + // server verifies challenge + derived, err := handleSecp256k1HelloChallengeAccepted(am, hca) + if err != nil { + t.Fatal(err) + } + + // server verifies if signer is identical to the derived key + if !derived.IsEqual(clientPubKey) { + t.Fatal("derived key is not the same as the advertised client key") + } +} + +func server(t *testing.T, want *int64) *httptest.Server { + handlerFunc := func(w http.ResponseWriter, r *http.Request) { + t.Logf("websocket") + defer t.Logf("websocket done") + + wao := &websocket.AcceptOptions{ + CompressionMode: websocket.CompressionContextTakeover, + OriginPatterns: []string{"localhost"}, + } + conn, err := websocket.Accept(w, r, wao) + if err != nil { + t.Fatalf("Failed to accept websocket connection for %v: %v", + r.RemoteAddr, err) + return + } + defer conn.Close(websocket.StatusNormalClosure, "") + + pconn := protocol.NewWSConn(conn) + + // Start handshake + serverAuth, err := NewSecp256k1AuthServer() + if err != nil { + t.Fatal(err) + } + t.Logf("server handshake starting") + ctx := context.TODO() + err = serverAuth.HandshakeServer(ctx, pconn) + if err != nil { + t.Fatalf("Failed to handshake connection for %v: %v", + r.RemoteAddr, err) + } + t.Logf("server connected to: %x", + serverAuth.RemotePublicKey().SerializeCompressed()) + + // Handshake complete, do required protocol ping + err = bssapi.Write(ctx, pconn, "requiredpingid", &bssapi.PingRequest{}) + if err != nil { + t.Fatalf("write required ping: %v", err) + } + + // Do a ping pong now + _, _, payload, err := bssapi.Read(ctx, pconn) + if err != nil { + t.Fatalf("read ping: %v", err) + } + t.Logf("server responding to ping %v", spew.Sdump(payload)) + *want = payload.(*bssapi.PingRequest).Timestamp + err = bssapi.Write(ctx, pconn, "pingid", &bssapi.PingResponse{ + Timestamp: *want, + }) + if err != nil { + t.Fatalf("write ping: %v", err) + } + } + + httpServer := httptest.NewServer(http.HandlerFunc(handlerFunc)) + return httpServer +} + +func TestProtocolHandshake(t *testing.T) { + var want int64 + testServer := server(t, &want) // launch server + + // client generated private and public keys + privKey, err := dcrsecpk256k1.GeneratePrivateKey() + if err != nil { + t.Fatal(err) + } + + clientAuth, err := NewSecp256k1AuthClient(privKey) + if err != nil { + t.Fatal(err) + } + + clientURI := testServer.URL + conn, err := protocol.NewConn(clientURI, clientAuth) + if err != nil { + t.Fatal(err) + } + ctx := context.TODO() + t.Logf("connect %v", clientURI) + err = conn.Connect(ctx) + if err != nil { + t.Fatal(err) + } + t.Logf("connected %v", clientURI) + + t.Logf("client writing ping") + err = bssapi.Write(ctx, conn, "ping-id", bssapi.PingRequest{ + Timestamp: time.Now().Unix(), + }) + _, _, payload, err := bssapi.Read(ctx, conn) + if err != nil { + t.Fatal(err) + } + t.Logf("client read ping %v", spew.Sdump(payload)) + got := payload.(*bssapi.PingResponse).Timestamp + if want != got { + t.Fatalf("unexpected ping response want %v got %v", want, got) + } +} diff --git a/api/bfgapi/bfgapi.go b/api/bfgapi/bfgapi.go new file mode 100644 index 00000000..5758aeb7 --- /dev/null +++ b/api/bfgapi/bfgapi.go @@ -0,0 +1,257 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package bfgapi + +import ( + "context" + "fmt" + "reflect" + + "github.com/hemilabs/heminetwork/api" + "github.com/hemilabs/heminetwork/api/protocol" + "github.com/hemilabs/heminetwork/hemi" +) + +const ( + APIVersion = 1 + + CmdPingRequest = "bfgapi-ping-request" + CmdPingResponse = "bfgapi-ping-response" + CmdPopTxForL2BlockRequest = "bfgapi-pop-txs-for-l2-block-request" + CmdPopTxForL2BlockResponse = "bfgapi-pop-txs-for-l2-block-response" + CmdNewL2KeystonesRequest = "bfgapi-new-l2-keystones-request" + CmdNewL2KeystonesResponse = "bfgapi-new-l2-keystones-response" + CmdBTCFinalityByRecentKeystonesRequest = "bfgapi-btc-finality-by-recent-keystones-request" + CmdBTCFinalityByRecentKeystonesResponse = "bfgapi-btc-finality-by-recent-keystones-response" + CmdBTCFinalityByKeystonesRequest = "bfgapi-btc-finality-by-keystones-request" + CmdBTCFinalityByKeystonesResponse = "bfgapi-btc-finality-by-keystones-response" + CmdBTCFinalityNotification = "bfgapi-btc-finality-notification" + CmdBTCNewBlockNotification = "bfgapi-btc-new-block-notification" + CmdL2KeystonesNotification = "bfgapi-l2-keystones-notification" + CmdL2KeystonesRequest = "bfgapi-l2-keystones-request" + CmdL2KeystonesResponse = "bfgapi-l2-keystones-response" + CmdBitcoinBalanceRequest = "bfgapi-bitcoin-balance-request" + CmdBitcoinBalanceResponse = "bfgapi-bitcoin-balance-response" + CmdBitcoinBroadcastRequest = "bfgapi-bitcoin-broadcast-request" + CmdBitcoinBroadcastResponse = "bfgapi-bitcoin-broadcast-response" + CmdBitcoinInfoRequest = "bfgapi-bitcoin-info-request" + CmdBitcoinInfoResponse = "bfgapi-bitcoin-info-response" + CmdBitcoinUTXOsRequest = "bfgapi-bitcoin-utxos-request" + CmdBitcoinUTXOsResponse = "bfgapi-bitcoin-utxos-response" + CmdAccessPublicKeyCreateRequest = "bfgapi-access-public-key-create-request" + CmdAccessPublicKeyCreateResponse = "bfgapi-access-public-key-create-response" + CmdAccessPublicKeyDeleteRequest = "bfgapi-access-public-key-delete-request" + CmdAccessPublicKeyDeleteResponse = "bfgapi-access-public-key-delete-response" +) + +var ( + APIVersionRoute = fmt.Sprintf("v%d", APIVersion) + RouteWebsocketPrivate = fmt.Sprintf("/%s/ws/private", APIVersionRoute) + RouteWebsocketPublic = fmt.Sprintf("/%s/ws/public", APIVersionRoute) + DefaultPrivateListen = "localhost:8080" + DefaultPublicListen = "localhost:8383" + DefaultPrometheusListen = "localhost:2112" + DefaultPrivateURL = "ws://" + DefaultPrivateListen + "/" + RouteWebsocketPrivate + DefaultPublicURL = "ws://" + DefaultPublicListen + "/" + RouteWebsocketPublic +) + +type AccessPublicKey struct { + PublicKey string `json:"public_key"` + CreatedAt string `json:"created_at" deep:"-"` +} + +// PingRequest and PingResponse are bfg-specific ping request/replies +type ( + PingRequest protocol.PingRequest + PingResponse protocol.PingResponse +) + +type NewL2KeystonesRequest struct { + L2Keystones []hemi.L2Keystone `json:"l2_keystones"` +} + +type NewL2KeystonesResponse struct { + Error *protocol.Error `json:"error,omitempty"` +} + +type L2KeystonesRequest struct { + NumL2Keystones uint64 `json:"num_l2_keystones"` +} + +type L2KeystonesResponse struct { + L2Keystones []hemi.L2Keystone `json:"l2_keystones"` + Error *protocol.Error `json:"error,omitempty"` +} + +type BitcoinBalanceRequest struct { + ScriptHash api.ByteSlice `json:"script_hash"` +} + +type BitcoinBalanceResponse struct { + Confirmed uint64 `json:"confirmed"` + Unconfirmed int64 `json:"unconfirmed"` + Error *protocol.Error `json:"error,omitempty"` +} + +type BitcoinBroadcastRequest struct { + Transaction api.ByteSlice `json:"transaction"` // XXX this needs to be plural +} + +type BitcoinBroadcastResponse struct { + TXID api.ByteSlice `json:"txid"` + Error *protocol.Error `json:"error,omitempty"` +} + +type BitcoinInfoRequest struct{} + +type BitcoinInfoResponse struct { + Height uint64 `json:"height"` + Error *protocol.Error `json:"error,omitempty"` +} + +type BitcoinUTXO struct { + Hash api.ByteSlice `json:"hash"` + Index uint32 `json:"index"` + Value int64 `json:"value"` +} + +type BitcoinUTXOsRequest struct { + ScriptHash api.ByteSlice `json:"script_hash"` +} + +type BitcoinUTXOsResponse struct { + UTXOs []*BitcoinUTXO `json:"utxos"` + Error *protocol.Error `json:"error,omitempty"` +} + +type PopTxsForL2BlockRequest struct { + L2Block api.ByteSlice `json:"l2_block"` +} + +type PopTxsForL2BlockResponse struct { + PopTxs []PopTx `json:"pop_txs"` + Error *protocol.Error `json:"error,omitempty"` +} + +type BTCFinalityByRecentKeystonesRequest struct { + NumRecentKeystones uint32 `json:"num_recent_keystones"` +} + +type BTCFinalityByRecentKeystonesResponse struct { + L2BTCFinalities []hemi.L2BTCFinality `json:"l2_btc_finalities"` + Error *protocol.Error `json:"error,omitempty"` +} + +type BTCFinalityByKeystonesRequest struct { + L2Keystones []hemi.L2Keystone `json:"l2_keystones"` +} + +type BTCFinalityByKeystonesResponse struct { + L2BTCFinalities []hemi.L2BTCFinality `json:"l2_btc_finalities"` + Error *protocol.Error `json:"error,omitempty"` +} + +type BTCFinalityNotification struct{} + +type BTCNewBlockNotification struct{} + +type L2KeystonesNotification struct{} + +type AccessPublicKeyCreateRequest struct { + PublicKey string `json:"public_key"` // encoded compressed public key +} + +type AccessPublicKeyCreateResponse struct { + Error *protocol.Error `json:"error,omitempty"` +} + +type AccessPublicKeyDeleteRequest struct { + PublicKey string `json:"public_key"` +} + +type AccessPublicKeyDeleteResponse struct { + Error *protocol.Error `json:"error,omitempty"` +} + +type PopTx struct { + BtcTxId api.ByteSlice `json:"btc_tx_id"` + BtcRawTx api.ByteSlice `json:"btc_raw_tx"` + BtcHeaderHash api.ByteSlice `json:"btc_header_hash"` + BtcTxIndex *uint64 `json:"btc_tx_index"` + BtcMerklePath []string `json:"btc_merkle_path"` + PopTxId api.ByteSlice `json:"pop_tx_id"` + PopMinerPublicKey api.ByteSlice `json:"pop_miner_public_key"` + L2KeystoneAbrevHash api.ByteSlice `json:"l2_keystone_abrev_hash"` +} + +var commands = map[protocol.Command]reflect.Type{ + CmdPingRequest: reflect.TypeOf(PingRequest{}), + CmdPingResponse: reflect.TypeOf(PingResponse{}), + CmdPopTxForL2BlockRequest: reflect.TypeOf(PopTxsForL2BlockRequest{}), + CmdPopTxForL2BlockResponse: reflect.TypeOf(PopTxsForL2BlockResponse{}), + CmdNewL2KeystonesRequest: reflect.TypeOf(NewL2KeystonesRequest{}), + CmdNewL2KeystonesResponse: reflect.TypeOf(NewL2KeystonesResponse{}), + CmdBTCFinalityByRecentKeystonesRequest: reflect.TypeOf(BTCFinalityByRecentKeystonesRequest{}), + CmdBTCFinalityByRecentKeystonesResponse: reflect.TypeOf(BTCFinalityByRecentKeystonesResponse{}), + CmdBTCFinalityByKeystonesRequest: reflect.TypeOf(BTCFinalityByKeystonesRequest{}), + CmdBTCFinalityByKeystonesResponse: reflect.TypeOf(BTCFinalityByKeystonesResponse{}), + CmdBTCFinalityNotification: reflect.TypeOf(BTCFinalityNotification{}), + CmdBTCNewBlockNotification: reflect.TypeOf(BTCNewBlockNotification{}), + CmdL2KeystonesNotification: reflect.TypeOf(L2KeystonesNotification{}), + CmdL2KeystonesRequest: reflect.TypeOf(L2KeystonesRequest{}), + CmdL2KeystonesResponse: reflect.TypeOf(L2KeystonesResponse{}), + CmdBitcoinBalanceRequest: reflect.TypeOf(BitcoinBalanceRequest{}), + CmdBitcoinBalanceResponse: reflect.TypeOf(BitcoinBalanceResponse{}), + CmdBitcoinBroadcastRequest: reflect.TypeOf(BitcoinBroadcastRequest{}), + CmdBitcoinBroadcastResponse: reflect.TypeOf(BitcoinBroadcastResponse{}), + CmdBitcoinInfoRequest: reflect.TypeOf(BitcoinInfoRequest{}), + CmdBitcoinInfoResponse: reflect.TypeOf(BitcoinInfoResponse{}), + CmdBitcoinUTXOsRequest: reflect.TypeOf(BitcoinUTXOsRequest{}), + CmdBitcoinUTXOsResponse: reflect.TypeOf(BitcoinUTXOsResponse{}), + CmdAccessPublicKeyCreateRequest: reflect.TypeOf(AccessPublicKeyCreateRequest{}), + CmdAccessPublicKeyCreateResponse: reflect.TypeOf(AccessPublicKeyCreateResponse{}), + CmdAccessPublicKeyDeleteRequest: reflect.TypeOf(AccessPublicKeyDeleteRequest{}), + CmdAccessPublicKeyDeleteResponse: reflect.TypeOf(AccessPublicKeyDeleteResponse{}), +} + +type bfgAPI struct{} + +func (a *bfgAPI) Commands() map[protocol.Command]reflect.Type { + return commands +} + +func APICommands() map[protocol.Command]reflect.Type { + return commands // XXX make copy +} + +// Write is the low level primitive of a protocol Write. One should generally +// not use this function and use WriteConn and Call instead. +func Write(ctx context.Context, c protocol.APIConn, id string, payload any) error { + return protocol.Write(ctx, c, &bfgAPI{}, id, payload) +} + +// Read is the low level primitive of a protocol Read. One should generally +// not use this function and use ReadConn instead. +func Read(ctx context.Context, c protocol.APIConn) (protocol.Command, string, any, error) { + return protocol.Read(ctx, c, &bfgAPI{}) +} + +// Call is a blocking call. One should use ReadConn when using Call or else the +// completion will end up in the Read instead of being completed as expected. +func Call(ctx context.Context, c *protocol.Conn, payload any) (protocol.Command, string, any, error) { + return c.Call(ctx, &bfgAPI{}, payload) +} + +// WriteConn writes to Conn. It is equivalent to Write but exists for symmetry +// reasons. +func WriteConn(ctx context.Context, c *protocol.Conn, id string, payload any) error { + return c.Write(ctx, &bfgAPI{}, id, payload) +} + +// ReadConn reads from Conn and performs callbacks. One should use ReadConn over +// Read when mixing Write, WriteConn and Call. +func ReadConn(ctx context.Context, c *protocol.Conn) (protocol.Command, string, any, error) { + return c.Read(ctx, &bfgAPI{}) +} diff --git a/api/bssapi/bssapi.go b/api/bssapi/bssapi.go new file mode 100644 index 00000000..b06ae169 --- /dev/null +++ b/api/bssapi/bssapi.go @@ -0,0 +1,166 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package bssapi + +import ( + "context" + "fmt" + "math/big" + "reflect" + + "github.com/ethereum/go-ethereum/common" + + "github.com/hemilabs/heminetwork/api" + "github.com/hemilabs/heminetwork/api/protocol" + "github.com/hemilabs/heminetwork/hemi" +) + +const ( + APIVersion = 1 +) + +var ( + APIVersionRoute = fmt.Sprintf("v%d", APIVersion) + RouteWebsocket = fmt.Sprintf("/%s/ws", APIVersionRoute) + DefaultListen = "localhost:8081" + DefaultPrometheusListen = "localhost:2112" + DefaultURL = "ws://" + DefaultListen + RouteWebsocket +) + +type PopPayout struct { + MinerAddress common.Address `json:"miner_address"` + Amount *big.Int `json:"amount"` +} + +type PopPayoutsRequest struct { + L2BlockForPayout api.ByteSlice `json:"l2_block_for_payout"` + + // these are unused at this point, they will be used in the future to determine the + // total payout to miners + PopDifficultyNumerator uint64 `json:"popDifficultyNumerator,omitempty"` + PopDifficultyDenominator uint64 `json:"popDifficultyDenominator,omitempty"` +} + +type PopPayoutsResponse struct { + PopPayouts []PopPayout `json:"pop_payouts"` + + // unused for now + PopScore uint64 `json:"pop_score,omitempty"` + + Error *protocol.Error `json:"error,omitempty"` +} + +type L2KeystoneRequest struct { + L2Keystone hemi.L2Keystone +} + +type L2KeystoneResponse struct { + Error *protocol.Error `json:"error,omitempty"` +} + +type OptimismKeystone hemi.L2Keystone // dop only + +// Websocket stuff follows. + +type ( + PingRequest protocol.PingRequest + PingResponse protocol.PingResponse +) + +type BTCFinalityByRecentKeystonesRequest struct { + NumRecentKeystones uint32 `json:"num_recent_keystones"` +} + +type BTCFinalityByRecentKeystonesResponse struct { + L2BTCFinalities []hemi.L2BTCFinality `json:"l2_btc_finalities"` + Error *protocol.Error `json:"error,omitempty"` +} + +type BTCFinalityByKeystonesRequest struct { + L2Keystones []hemi.L2Keystone `json:"l2_keystones"` +} + +type BTCFinalityByKeystonesResponse struct { + L2BTCFinalities []hemi.L2BTCFinality `json:"l2_btc_finalities"` + Error *protocol.Error `json:"error,omitempty"` +} + +type BTCFinalityNotification struct{} + +type BTCNewBlockNotification struct{} + +const ( + // Generic RPC commands + CmdPingRequest = "bssapi-ping-request" + CmdPingResponse = "bssapi-ping-response" + + // Custom RPC commands + CmdPopPayoutRequest protocol.Command = "bssapi-pop-payout-request" + CmdPopPayoutResponse protocol.Command = "bssapi-pop-payout-response" + CmdL2KeystoneRequest protocol.Command = "bssapi-l2-keystone-request" + CmdL2KeystoneResponse protocol.Command = "bssapi-l2-keystone-response" + CmdBTCFinalityByRecentKeystonesRequest protocol.Command = "bssapi-btc-finality-by-recent-keystones-request" + CmdBTCFinalityByRecentKeystonesResponse protocol.Command = "bssapi-btc-finality-by-recent-keystones-response" + CmdBTCFinalityByKeystonesRequest protocol.Command = "bssapi-btc-finality-by-keystones-request" + CmdBTCFinalityByKeystonesResponse protocol.Command = "bssapi-btc-finality-by-keystones-response" + CmdBTCFinalityNotification protocol.Command = "bssapi-btc-finality-notification" + CmdBTCNewBlockNotification protocol.Command = "bssapi-btc-new-block-notification" +) + +// commands contains the command key and type. This is used during RPC calls. +var commands = map[protocol.Command]reflect.Type{ + CmdPingRequest: reflect.TypeOf(PingRequest{}), + CmdPingResponse: reflect.TypeOf(PingResponse{}), + CmdPopPayoutRequest: reflect.TypeOf(PopPayoutsRequest{}), + CmdPopPayoutResponse: reflect.TypeOf(PopPayoutsResponse{}), + CmdL2KeystoneRequest: reflect.TypeOf(L2KeystoneRequest{}), + CmdL2KeystoneResponse: reflect.TypeOf(L2KeystoneResponse{}), + CmdBTCFinalityByRecentKeystonesRequest: reflect.TypeOf(BTCFinalityByRecentKeystonesRequest{}), + CmdBTCFinalityByRecentKeystonesResponse: reflect.TypeOf(BTCFinalityByRecentKeystonesResponse{}), + CmdBTCFinalityByKeystonesRequest: reflect.TypeOf(BTCFinalityByKeystonesRequest{}), + CmdBTCFinalityByKeystonesResponse: reflect.TypeOf(BTCFinalityByKeystonesResponse{}), + CmdBTCFinalityNotification: reflect.TypeOf(BTCFinalityNotification{}), + CmdBTCNewBlockNotification: reflect.TypeOf(BTCNewBlockNotification{}), +} + +// apiCmd is an empty structure used to satisfy the protocol.API interface. +type apiCmd struct{} + +// Commands satisfies the protocol.API interface. +func (a *apiCmd) Commands() map[protocol.Command]reflect.Type { + return commands +} + +func APICommands() map[protocol.Command]reflect.Type { + return commands // XXX make copy +} + +// Read reads a command from an APIConn. This is used server side. +func Read(ctx context.Context, c protocol.APIConn) (protocol.Command, string, any, error) { + return protocol.Read(ctx, c, &apiCmd{}) +} + +// Write writes a command to an APIConn. This is used server side. +func Write(ctx context.Context, c protocol.APIConn, id string, payload any) error { + return protocol.Write(ctx, c, &apiCmd{}, id, payload) +} + +// Call executes a blocking RPC call. Note that this requires the client to +// provide a ReadConn in a for loop in order to receive commands. This may be +// fixed in the future but seems simple enough to just leave alone for now. The +// need for the ReadConn loop is because apiCmd is not exported. +func Call(ctx context.Context, c *protocol.Conn, payload any) (protocol.Command, string, any, error) { + return c.Call(ctx, &apiCmd{}, payload) +} + +// ReadConn reads a command from a protocol.Conn. This is used client side. +func ReadConn(ctx context.Context, c *protocol.Conn) (protocol.Command, string, any, error) { + return c.Read(ctx, &apiCmd{}) +} + +// WriteConn writes a command to a protocol.Conn. This is used client side. +func WriteConn(ctx context.Context, c *protocol.Conn, id string, payload any) error { + return c.Write(ctx, &apiCmd{}, id, payload) +} diff --git a/api/protocol/protocol.go b/api/protocol/protocol.go new file mode 100644 index 00000000..09a9ddd1 --- /dev/null +++ b/api/protocol/protocol.go @@ -0,0 +1,549 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package protocol + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "reflect" + "sync" + "time" + + "github.com/juju/loggo" + "nhooyr.io/websocket" + "nhooyr.io/websocket/wsjson" +) + +var log = loggo.GetLogger("protocol") + +const ( + logLevel = "protocol=INFO" + WSConnectTimeout = 20 * time.Second + WSHandshakeTimeout = 15 * time.Second +) + +const ( + StatusHandshakeErr websocket.StatusCode = 4100 // XXX can we just hijack 4100? +) + +type HandshakeError string + +func (he HandshakeError) Error() string { + return string(he) +} + +func (he HandshakeError) Is(target error) bool { + _, ok := target.(HandshakeError) + return ok +} + +var PublicKeyAuthError = websocket.CloseError{ + Code: StatusHandshakeErr, + Reason: HandshakeError("invalid public key").Error(), +} + +func init() { + loggo.ConfigureLoggers(logLevel) +} + +// random returns a variable number of random bytes. +func random(n int) ([]byte, error) { + buffer := make([]byte, n) + _, err := io.ReadFull(rand.Reader, buffer) + if err != nil { + return nil, err + } + return buffer, nil +} + +var ErrInvalidCommand = errors.New("invalid command") + +type Command string + +// commandPayload returns the data structure corresponding to the given command. +func commandPayload(cmd Command, api API) (reflect.Type, bool) { + commands := api.Commands() + payload, ok := commands[cmd] + return payload, ok +} + +// commandFromPayload returns the command for the given data structure. +func commandFromPayload(payload any, api API) (Command, bool) { + payloadType := reflect.TypeOf(payload) + for cmd, cmdPayloadType := range api.Commands() { + cmdPayloadPtrType := reflect.PointerTo(cmdPayloadType) + if payloadType == cmdPayloadType || payloadType == cmdPayloadPtrType { + return cmd, true + } + } + return "", false +} + +// fixupStruct iterates over a struct in order to fix up nil slices. +func fixupStruct(v reflect.Value) { + if v.Type().Kind() != reflect.Struct { + return + } + for i := 0; i < v.NumField(); i++ { + fv := v.Field(i) + fk := fv.Type().Kind() + if fk == reflect.Ptr { + if fv.IsNil() { + continue + } + fv = reflect.Indirect(fv) + fk = fv.Type().Kind() + } + switch fk { + case reflect.Slice: + fixupNilSlice(fv) + case reflect.Struct: + fixupStruct(fv) + } + } +} + +// fixupNilSlice changes a nil slice to an empty slice if it is setable. +func fixupNilSlice(v reflect.Value) { + if v.Type().Kind() != reflect.Slice { + return + } + if !v.IsNil() || !v.CanSet() { + return + } + v.Set(reflect.MakeSlice(v.Type(), 0, 0)) +} + +// fixupNilSlices fixes up nil slices to empty slices in a struct and any +// nested structs. +func fixupNilSlices(i any) { + v := reflect.Indirect(reflect.ValueOf(i)) + switch v.Type().Kind() { + case reflect.Slice: + fixupNilSlice(v) + case reflect.Struct: + fixupStruct(v) + } +} + +type API interface { + Commands() map[Command]reflect.Type +} + +func Read(ctx context.Context, c APIConn, api API) (Command, string, interface{}, error) { + var msg Message + if err := c.ReadJSON(ctx, &msg); err != nil { + return "", "", nil, err + } + cmdPayload, ok := commandPayload(msg.Header.Command, api) + if !ok { + return "", "", nil, ErrInvalidCommand + } + + payload := reflect.New(cmdPayload).Interface() + if err := json.Unmarshal(msg.Payload, &payload); err != nil { + return "", "", nil, ErrInvalidCommand + } + + return msg.Header.Command, msg.Header.ID, payload, nil +} + +// Write encodes and sends a payload over the API connection. +func Write(ctx context.Context, c APIConn, api API, id string, payload interface{}) error { + cmd, ok := commandFromPayload(payload, api) + if !ok { + return fmt.Errorf("command unknown for payload %T", payload) + } + + // Go's JSON encoder encodes a nil slice as "null" and an empty + // array as "[]" - react does not cope with this, so convert nil + // slices to empty slices. In order to do this we need to copy + // the payload so as not to modify the original. Yay. + b, err := json.Marshal(payload) + if err != nil { + return err + } + clone := reflect.New(reflect.TypeOf(payload)).Interface() + if err := json.Unmarshal(b, clone); err != nil { + return err + } + fixupNilSlices(clone) + + msg := &Message{ + Header: Header{Command: cmd, ID: id}, + } + msg.Payload, err = json.Marshal(clone) + if err != nil { + return err + } + + return c.WriteJSON(ctx, msg) +} + +// Authenticator implements authentication between a client and a server. +type Authenticator interface { + HandshakeClient(ctx context.Context, ac APIConn) error + HandshakeServer(ctx context.Context, ac APIConn) error +} + +type WSConn struct { + conn *websocket.Conn +} + +func (wsc *WSConn) ReadJSON(ctx context.Context, v any) error { + return wsjson.Read(ctx, wsc.conn, v) +} + +func (wsc *WSConn) WriteJSON(ctx context.Context, v any) error { + return wsjson.Write(ctx, wsc.conn, v) +} + +func (wsc *WSConn) Close() error { + return wsc.conn.Close(websocket.StatusNormalClosure, "") +} + +func (wsc *WSConn) CloseStatus(code websocket.StatusCode, reason string) error { + return wsc.conn.Close(code, reason) +} + +func NewWSConn(conn *websocket.Conn) *WSConn { + return &WSConn{conn: conn} +} + +// Header prefixes all websocket commands. +type Header struct { + Command Command `json:"command"` // Command to execute + ID string `json:"id,omitempty"` // Command identifier +} + +// Message represents a websocket message. +type Message struct { + Header Header `json:"header"` + Payload json.RawMessage `json:"payload"` +} + +// Error is a protocol Error type that can be used for additional error +// context. It embeds an 8 byte number that can be used to trace calls on both the +// client and server side. +type Error struct { + Timestamp int64 `json:"timestamp"` + Trace string `json:"trace"` + Message string `json:"error"` +} + +// Errorf is a client induced protocol error (e.g. "invalid height"). This is a +// pretty printable error on the client and server and is not fatal. +func Errorf(msg string, args ...interface{}) *Error { + trace, _ := random(8) + return &Error{ + Timestamp: time.Now().Unix(), + Trace: hex.EncodeToString(trace), + Message: fmt.Sprintf(msg, args...), + } +} + +func (e Error) String() string { + return fmt.Sprintf("%v [%v:%v]", e.Message, e.Trace, e.Timestamp) +} + +// Ping +type PingRequest struct { + Timestamp int64 `json:"timestamp"` // Local timestamp +} + +// PingResponse +type PingResponse struct { + OriginTimestamp int64 `json:"origintimestamp"` // Timestamp from origin + Timestamp int64 `json:"timestamp"` // Local timestamp +} + +// APIConn provides an API connection. +type APIConn interface { + ReadJSON(ctx context.Context, v any) error + WriteJSON(ctx context.Context, v any) error +} + +// readResult is the result of a client side read. +type readResult struct { + cmd Command + id string + payload interface{} + err error +} + +// Conn is a client side connection. +type Conn struct { + sync.RWMutex + + serverURL string + auth Authenticator + msgID uint64 + + wsc *websocket.Conn + wscReadLock sync.Mutex + wscWriteLock sync.Mutex + + calls map[string]chan *readResult +} + +// NewConn returns a client side connection object. +func NewConn(urlStr string, authenticator Authenticator) (*Conn, error) { + log.Tracef("NewConn: %v", urlStr) + defer log.Tracef("NewConn exit: %v", urlStr) + + u, err := url.Parse(urlStr) + if err != nil { + return nil, err + } + + ac := &Conn{ + serverURL: u.String(), + auth: authenticator, + calls: make(map[string]chan *readResult), + msgID: 1, + } + + return ac, nil +} + +func (ac *Conn) Connect(ctx context.Context) error { + log.Tracef("Connect") + defer log.Tracef("Connect exit") + + ac.Lock() + defer ac.Unlock() + if ac.wsc != nil { + return nil + } + + // Connection and handshake must complete in less than WSConnectTimeout. + connectCtx, cancel := context.WithTimeout(ctx, WSConnectTimeout) + defer cancel() + + // XXX Dial does not return a parasable error. This is an issue in the + // package. + // Note that we cannot have DialOptions on a WASM websocket + log.Tracef("Connect: dialing %v", ac.serverURL) + conn, _, err := websocket.Dial(connectCtx, ac.serverURL, nil) + if err != nil { + return fmt.Errorf("failed to dial server: %v", err) + } + conn.SetReadLimit(512 * 1024) // XXX - default is 32KB + defer func() { + if ac.wsc == nil { + conn.Close(websocket.StatusNormalClosure, "") + } + }() + + handshakeCtx, cancel := context.WithTimeout(ctx, WSHandshakeTimeout) + defer cancel() + + if ac.auth != nil { + log.Tracef("Connect: handshaking with %v", ac.serverURL) + if err := ac.auth.HandshakeClient(handshakeCtx, NewWSConn(conn)); err != nil { + return HandshakeError(fmt.Sprintf("failed to handshake with server: %v", err)) + } + } + + // done as an API message and it should be done at the protocol + // level instead... + var msg Message + if err := NewWSConn(conn).ReadJSON(connectCtx, &msg); err != nil { + var ce websocket.CloseError + if errors.As(err, &ce) { + switch ce.Code { + // case 4000: + // log.Errorf("Connection rejected - user account not found") + // return ErrUserAccountNotFound + default: + log.Errorf("unknown close error: %v", err) + return err + } + } + log.Errorf("Connection to %v failed: %v", ac.serverURL, err) + return err + } + + log.Debugf("Connection established with %v", ac.serverURL) + ac.wsc = conn + + return nil +} + +// wsConn returns the underlying webscket connection. +func (ac *Conn) wsConn() *websocket.Conn { + ac.RLock() + defer ac.RUnlock() + return ac.wsc +} + +// conn (re)connects an existing websocket connection. +func (ac *Conn) conn(ctx context.Context) (*websocket.Conn, error) { + wsc := ac.wsConn() + if wsc != nil { + return wsc, nil + } + if err := ac.Connect(ctx); err != nil { + return nil, err + } + return ac.wsConn(), nil +} + +// CloseStatus close the connection with the provided StatusCode. +func (ac *Conn) CloseStatus(code websocket.StatusCode, reason string) error { + ac.Lock() + defer ac.Unlock() + if ac.wsc == nil { + return nil + } + err := ac.wsc.Close(code, reason) + ac.wsc = nil + + return err +} + +func (ac *Conn) IsOnline() bool { + ac.Lock() + defer ac.Unlock() + return ac.wsc != nil +} + +// Close close a websocket connection with normal status. +func (ac *Conn) Close() error { + return ac.CloseStatus(websocket.StatusNormalClosure, "") +} + +// ReadJSON returns JSON of the wire and unmarshals it into v. +func (ac *Conn) ReadJSON(ctx context.Context, v any) error { + conn, err := ac.conn(ctx) + if err != nil { + return err + } + ac.wscReadLock.Lock() + defer ac.wscReadLock.Unlock() + if err := wsjson.Read(ctx, conn, v); err != nil { + ac.Close() + return err + } + return nil +} + +// WriteJSON writes marshals v and writes it to the wire. +func (ac *Conn) WriteJSON(ctx context.Context, v any) error { + conn, err := ac.conn(ctx) + if err != nil { + return err + } + + ac.wscWriteLock.Lock() + defer ac.wscWriteLock.Unlock() + if err := wsjson.Write(ctx, conn, v); err != nil { + ac.Close() + return err + } + return nil +} + +// read calls the underlying Read function and returns the command, id and +// unmarshaled payload. +func (ac *Conn) read(ctx context.Context, api API) (Command, string, interface{}, error) { + return Read(ctx, ac, api) +} + +// nextMsgID returns the next available message identifier. This identifier +// travels as part of the header with the command. +func (ac *Conn) nextMsgID() uint64 { + ac.Lock() + defer ac.Unlock() + msgID := ac.msgID + ac.msgID++ + if ac.msgID == 0 { + ac.msgID++ + } + return msgID +} + +// Call is a blocking call that returns the command, id and unmarshaled payload. +func (ac *Conn) Call(ctx context.Context, api API, payload interface{}) (Command, string, interface{}, error) { + log.Tracef("Call: %T", payload) + defer log.Tracef("Call exit: %T", payload) + + msgID := fmt.Sprintf("%d", ac.nextMsgID()) + resultCh := make(chan *readResult, 1) + + ac.Lock() + ac.calls[msgID] = resultCh + ac.Unlock() + + defer func() { + ac.Lock() + delete(ac.calls, msgID) + ac.Unlock() + }() + + if err := ac.Write(ctx, api, msgID, payload); err != nil { + return "", "", nil, err + } + var result *readResult + select { + case <-ctx.Done(): + return "", "", nil, ctx.Err() + case result = <-resultCh: + } + + if result.err == nil && result.payload == nil { + result.err = errors.New("reply payload is nil") + } + + return result.cmd, result.id, result.payload, result.err +} + +// errorAll fails all outstanding commands in order to shutdown the websocket. +func (ac *Conn) errorAll(err error) { + ac.RLock() + defer ac.RUnlock() + for _, call := range ac.calls { + rr := &readResult{err: err} + select { + case call <- rr: + default: + } + } +} + +// Read reads and returns the next command from the API connection. +func (ac *Conn) Read(ctx context.Context, api API) (Command, string, interface{}, error) { + for { + cmd, id, payload, err := ac.read(ctx, api) + if id == "" || err != nil { + if err != nil { + ac.errorAll(err) + } + return cmd, id, payload, err + } + ac.RLock() + call, ok := ac.calls[id] + ac.RUnlock() + if !ok { + return cmd, id, payload, err + } + rr := &readResult{cmd, id, payload, err} + select { + case call <- rr: + default: + } + } +} + +// Write encodes and sends a payload over the API connection. +func (ac *Conn) Write(ctx context.Context, api API, id string, payload interface{}) error { + return Write(ctx, ac, api, id, payload) +} diff --git a/bitcoin/bitcoin.go b/bitcoin/bitcoin.go new file mode 100644 index 00000000..964994a3 --- /dev/null +++ b/bitcoin/bitcoin.go @@ -0,0 +1,164 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package bitcoin + +import ( + "bytes" + "encoding/hex" + "errors" + "fmt" + "slices" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + dcrsecp256k1 "github.com/decred/dcrd/dcrec/secp256k1/v4" + dcrecdsa "github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa" +) + +type BlockHeader [80]byte + +func (bh *BlockHeader) String() string { + return hex.EncodeToString(bh[:]) +} + +func RawBlockHeaderFromSlice(s []byte) (*BlockHeader, error) { + if len(s) != 80 { + return nil, fmt.Errorf("invalid blockheader size") + } + var bh BlockHeader + copy(bh[:], s) + return &bh, nil +} + +var ErrInvalidMerkle = errors.New("invalid merkle") + +func MerkleRootFromBlockHeader(bh *BlockHeader) []byte { + return bh[36:68] +} + +// ValidateMerkleRoot accepts encoded hashes of the hashes in question, +// i.e. the tx hash, merkle proof steps, and merkle root. +// and the index of the transaction. returns nil if the merkle proof is valid +func ValidateMerkleRoot(txHashEncoded string, merkleProofEncoded []string, txIndex uint32, merkleRootEncoded string) error { + txHash, err := hex.DecodeString(txHashEncoded) + if err != nil { + return err + } + + merkleProof := [][]byte{} + for _, v := range merkleProofEncoded { + decoded, err := hex.DecodeString(v) + if err != nil { + return err + } + + // these are stored in reverse-byte order, reverse each + slices.Reverse(decoded) + merkleProof = append(merkleProof, decoded) + } + + merkleRoot, err := hex.DecodeString(merkleRootEncoded) + if err != nil { + return err + } + + if err := CheckMerkleChain(txHash, txIndex, merkleProof, merkleRoot); err != nil { + return errors.Join(ErrInvalidMerkle, err) + } + + return nil +} + +func CheckMerkleChain(leaf []byte, index uint32, merkleHashes [][]byte, merkleRoot []byte) error { + if len(leaf) != chainhash.HashSize { + return fmt.Errorf("invalid leaf hash length (%d != %d)", len(leaf), chainhash.HashSize) + } + + b := append([]byte{}, leaf...) + for _, merkleHash := range merkleHashes { + if len(merkleHash) != chainhash.HashSize { + return fmt.Errorf("invalid merkle hash length (%d != %d)", len(merkleHash), chainhash.HashSize) + } + if index%2 == 0 { + // Right. + b = chainhash.DoubleHashB(append(b, merkleHash...)) + } else { + // Left. + b = chainhash.DoubleHashB(append(merkleHash, b...)) + } + index /= 2 + } + + if len(merkleRoot) != chainhash.HashSize { + return fmt.Errorf("invalid merkle root length (%d != %d)", len(merkleRoot), chainhash.HashSize) + } + if !bytes.Equal(merkleRoot, b) { + return fmt.Errorf("merkle root mismatch (%x != %x)", merkleRoot, b) + } + return nil +} + +func SignTx(btx *wire.MsgTx, payToScript []byte, privateKey *dcrsecp256k1.PrivateKey, publicKey *dcrsecp256k1.PublicKey) error { + if btx == nil { + return errors.New("btx cannot be nil") + } + + if !slices.Equal(privateKey.PubKey().SerializeUncompressed(), + publicKey.SerializeUncompressed()) { + return errors.New("wrong public key for private key") + } + + sigHash, err := txscript.CalcSignatureHash(payToScript, + txscript.SigHashAll, btx, 0, + ) + if err != nil { + return fmt.Errorf("failed to calculate signature hash: %v", err) + } + pubKeyBytes := publicKey.SerializeCompressed() + sig := dcrecdsa.Sign(privateKey, sigHash) + sigBytes := append(sig.Serialize(), byte(txscript.SigHashAll)) + sb := txscript.NewScriptBuilder().AddData(sigBytes).AddData(pubKeyBytes) + if btx.TxIn[0].SignatureScript, err = sb.Script(); err != nil { + return fmt.Errorf("failed to build signature script: %v", err) + } + return nil +} + +func PrivKeyFromHexString(s string) (*dcrsecp256k1.PrivateKey, error) { + var privKeyBytes [32]byte + b, err := hex.DecodeString(s) + if err != nil { + return nil, err + } + if len(b) != len(privKeyBytes) { + return nil, fmt.Errorf("incorrect length (%d != %d)", len(b), len(privKeyBytes)) + } + copy(privKeyBytes[:], b) + privKey := new(dcrsecp256k1.PrivateKey) + overflow := privKey.Key.SetBytes(&privKeyBytes) + if privKey.Key.IsZeroBit() != 0 || overflow != 0 { + return nil, errors.New("out of range") + } + return privKey, nil +} + +func KeysAndAddressFromHexString(s string, chainParams *chaincfg.Params) (*dcrsecp256k1.PrivateKey, *dcrsecp256k1.PublicKey, *btcutil.AddressPubKeyHash, error) { + privKey, err := PrivKeyFromHexString(s) + if err != nil { + return nil, nil, nil, fmt.Errorf("invalid BTC private key: %v", err) + } + + pubKeyBytes := privKey.PubKey().SerializeCompressed() + btcAddress, err := btcutil.NewAddressPubKey(pubKeyBytes, chainParams) + if err != nil { + return nil, nil, nil, + fmt.Errorf("failed to create BTC address from public key: %v", err) + } + + return privKey, privKey.PubKey(), btcAddress.AddressPubKeyHash(), nil +} diff --git a/bitcoin/bitcoin_test.go b/bitcoin/bitcoin_test.go new file mode 100644 index 00000000..ee691405 --- /dev/null +++ b/bitcoin/bitcoin_test.go @@ -0,0 +1,257 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package bitcoin + +import ( + "errors" + "testing" +) + +func TestValidateMerklePathOne(t *testing.T) { + index := 3 + + txHash := "3eeebe233e20859f7d888521c58d052a65dbf728b7a968af2efdfd79655ebff9" + + merkleRoot := "8d959c33bb0c2cbd1a6ebb24411fc71fb7084e357edb1115f567b0bc374168db" + + merkleHashes := []string{ + "caa404f96ff2560078bdcc8752842cc628bbf7555a0f8e23985727e36789167b", + "170237c6f86c619b3d65945795dfe52f1889e2e62a0ea9848c7f381f16292a45", + "8d2ce625665e9a11986530d7bb7ab5c6549273d4bc92f09ca0ab1c1b7251aafb", + "1423d272aecbdccb7d72e94adb17981e34e4b43a3ed079ae9743ff3636096333", + } + + err := ValidateMerkleRoot(txHash, merkleHashes, uint32(index), merkleRoot) + if err != nil { + t.Fatal(err) + } +} + +func TestValidateMerklePathTwo(t *testing.T) { + index := 6 + + txHash := "d92ae4f9e9d7c6097219b807c6186d2cd3266b408fb51979bdff6e97ccd40fb3" + + merkleRoot := "e9761619749fa9b71e57e8b43deaa17a275ae6c50ecd8bc9d306fd405206bc4c" + + merkleHashes := []string{ + "2455f072af2695304fda4cf262f23b60ab7cc2b474291f1801738a390c1da6f4", + "aab2e23cc321faa4c7b8f1318e9f2a4e2d591a15923eccb3e221c43af818b438", + "a7f14dcd5493b1987c927810e8e29f91f3235d9517c76b839c5d6abc10103274", + "af9c8b69f2744e28a06a94746204c238a122221746da1c73fdb9f224fc688f15", + "198208c8d262af23cadf3ba980c962e1fd69d6971e9241c5e92ccae6cefa431e", + "c4c4257a09409325e1e06ef2d622dc295d3dd404375dc6b7771de24c9413b0fb", + "04dfb92d02b3ad2eaad3930b34993c39bb138a3c1102bc29045e342d6bf1e480", + "b591703500d8bcecca79361efd7a3d112cbc162d27df10b80c875e51ffa27680", + } + + err := ValidateMerkleRoot(txHash, merkleHashes, uint32(index), merkleRoot) + if err != nil { + t.Fatal(err) + } +} + +func TestValidateInvalidMerklePathOne(t *testing.T) { + index := 6 + + txHash := "e92ae4f9e9d7c6097219b807c6186d2cd3266b408fb51979bdff6e97ccd40fb3" + + merkleRoot := "4cbc065240fd06d3c98bcd0ec5e65a277aa1ea3db4e8571eb7a99f74191676e9" + + merkleHashes := []string{ + "2455f072af2695304fda4cf262f23b60ab7cc2b474291f1801738a390c1da6f4", + "aab2e23cc321faa4c7b8f1318e9f2a4e2d591a15923eccb3e221c43af818b438", + "a7f14dcd5493b1987c927810e8e29f91f3235d9517c76b839c5d6abc10103274", + "af9c8b69f2744e28a06a94746204c238a122221746da1c73fdb9f224fc688f15", + "198208c8d262af23cadf3ba980c962e1fd69d6971e9241c5e92ccae6cefa431e", + "c4c4257a09409325e1e06ef2d622dc295d3dd404375dc6b7771de24c9413b0fb", + "04dfb92d02b3ad2eaad3930b34993c39bb138a3c1102bc29045e342d6bf1e480", + "b591703500d8bcecca79361efd7a3d112cbc162d27df10b80c875e51ffa27680", + } + + err := ValidateMerkleRoot(txHash, merkleHashes, uint32(index), merkleRoot) + if err == nil || errors.Is(err, ErrInvalidMerkle) == false { + t.Fatalf("unexpected error %s", err) + } +} + +func TestCheckMerkleChain(t *testing.T) { + tests := []struct { + leaf []byte + index uint32 + merkleHashes [][]byte + merkleRoot []byte + }{ + { + leaf: []byte{ + 0x14, 0x06, 0xe0, 0x58, 0x81, 0xe2, 0x99, 0x36, + 0x77, 0x66, 0xd3, 0x13, 0xe2, 0x6c, 0x05, 0x56, + 0x4e, 0xc9, 0x1b, 0xf7, 0x21, 0xd3, 0x17, 0x26, + 0xbd, 0x6e, 0x46, 0xe6, 0x06, 0x89, 0x53, 0x9a, + }, + index: 0, + merkleHashes: [][]byte{ + { + 0x9c, 0x12, 0xcf, 0xdc, 0x04, 0xc7, 0x45, 0x84, + 0xd7, 0x87, 0xac, 0x3d, 0x23, 0x77, 0x21, 0x32, + 0xc1, 0x85, 0x24, 0xbc, 0x7a, 0xb2, 0x8d, 0xec, + 0x42, 0x19, 0xb8, 0xfc, 0x5b, 0x42, 0x5f, 0x70, + }, + { + 0x54, 0x69, 0xb9, 0xf8, 0x68, 0x8b, 0xf3, 0x33, + 0x2b, 0x52, 0x54, 0x8d, 0x8c, 0x9b, 0x1e, 0x3f, + 0x05, 0x5d, 0x44, 0x91, 0x9e, 0x81, 0x7b, 0x13, + 0x9c, 0x0c, 0x12, 0x23, 0xe8, 0x21, 0xc8, 0xe1, + }, + { + 0x10, 0x83, 0xf3, 0xc1, 0x37, 0x2c, 0x20, 0xbe, + 0x05, 0x3f, 0xd1, 0xd0, 0x2a, 0xa6, 0x00, 0xc0, + 0xc5, 0xf1, 0xa9, 0x21, 0x41, 0x90, 0x63, 0x08, + 0xa8, 0x17, 0x4f, 0x60, 0x49, 0x1a, 0x4d, 0xcd, + }, + { + 0xea, 0xfe, 0x80, 0x20, 0x54, 0xa4, 0x03, 0x3d, + 0x2a, 0x75, 0xec, 0x02, 0x13, 0x9a, 0x40, 0xbc, + 0x04, 0x99, 0xa5, 0x75, 0x48, 0x59, 0xab, 0xc6, + 0x8b, 0x1e, 0x54, 0x77, 0xe3, 0xd4, 0xdb, 0x4d, + }, + }, + merkleRoot: []byte{ + 0x75, 0x29, 0x58, 0x7e, 0x6a, 0xc1, 0x58, 0xc0, + 0x29, 0x1c, 0x76, 0x36, 0x27, 0x8d, 0x88, 0x5a, + 0xaa, 0x78, 0xaf, 0xb1, 0x03, 0x14, 0xae, 0x39, + 0x95, 0xec, 0x19, 0x42, 0xe0, 0xbd, 0x7a, 0x7c, + }, + }, + { + leaf: []byte{ + 0x9c, 0x12, 0xcf, 0xdc, 0x04, 0xc7, 0x45, 0x84, + 0xd7, 0x87, 0xac, 0x3d, 0x23, 0x77, 0x21, 0x32, + 0xc1, 0x85, 0x24, 0xbc, 0x7a, 0xb2, 0x8d, 0xec, + 0x42, 0x19, 0xb8, 0xfc, 0x5b, 0x42, 0x5f, 0x70, + }, + index: 1, + merkleHashes: [][]byte{ + { + 0x14, 0x06, 0xe0, 0x58, 0x81, 0xe2, 0x99, 0x36, + 0x77, 0x66, 0xd3, 0x13, 0xe2, 0x6c, 0x05, 0x56, + 0x4e, 0xc9, 0x1b, 0xf7, 0x21, 0xd3, 0x17, 0x26, + 0xbd, 0x6e, 0x46, 0xe6, 0x06, 0x89, 0x53, 0x9a, + }, + { + 0x54, 0x69, 0xb9, 0xf8, 0x68, 0x8b, 0xf3, 0x33, + 0x2b, 0x52, 0x54, 0x8d, 0x8c, 0x9b, 0x1e, 0x3f, + 0x05, 0x5d, 0x44, 0x91, 0x9e, 0x81, 0x7b, 0x13, + 0x9c, 0x0c, 0x12, 0x23, 0xe8, 0x21, 0xc8, 0xe1, + }, + { + 0x10, 0x83, 0xf3, 0xc1, 0x37, 0x2c, 0x20, 0xbe, + 0x05, 0x3f, 0xd1, 0xd0, 0x2a, 0xa6, 0x00, 0xc0, + 0xc5, 0xf1, 0xa9, 0x21, 0x41, 0x90, 0x63, 0x08, + 0xa8, 0x17, 0x4f, 0x60, 0x49, 0x1a, 0x4d, 0xcd, + }, + { + 0xea, 0xfe, 0x80, 0x20, 0x54, 0xa4, 0x03, 0x3d, + 0x2a, 0x75, 0xec, 0x02, 0x13, 0x9a, 0x40, 0xbc, + 0x04, 0x99, 0xa5, 0x75, 0x48, 0x59, 0xab, 0xc6, + 0x8b, 0x1e, 0x54, 0x77, 0xe3, 0xd4, 0xdb, 0x4d, + }, + }, + merkleRoot: []byte{ + 0x75, 0x29, 0x58, 0x7e, 0x6a, 0xc1, 0x58, 0xc0, + 0x29, 0x1c, 0x76, 0x36, 0x27, 0x8d, 0x88, 0x5a, + 0xaa, 0x78, 0xaf, 0xb1, 0x03, 0x14, 0xae, 0x39, + 0x95, 0xec, 0x19, 0x42, 0xe0, 0xbd, 0x7a, 0x7c, + }, + }, + { + leaf: []byte{ + 0xb6, 0xd5, 0x8d, 0xfa, 0x65, 0x47, 0xc1, 0xeb, + 0x7f, 0x0d, 0x4f, 0xfd, 0x3e, 0x3b, 0xd6, 0x45, + 0x22, 0x13, 0x21, 0x0e, 0xa5, 0x1b, 0xaa, 0x70, + 0xb9, 0x7c, 0x31, 0xf0, 0x11, 0x18, 0x72, 0x15, + }, + index: 7, + merkleHashes: [][]byte{ + { + 0xf3, 0x03, 0x5c, 0x79, 0xa8, 0x4a, 0x2d, 0xda, + 0x7a, 0x7b, 0x5f, 0x35, 0x6b, 0x3a, 0xeb, 0x82, + 0xfb, 0x93, 0x4d, 0x5f, 0x12, 0x6a, 0xf9, 0x9b, + 0xbe, 0xe9, 0xa4, 0x04, 0xc4, 0x25, 0xb8, 0x88, + }, + { + 0x9b, 0x6a, 0x80, 0xad, 0xbf, 0xaf, 0x86, 0x36, + 0xcc, 0x89, 0x70, 0x28, 0xdb, 0x2a, 0x28, 0xc4, + 0x31, 0xa9, 0xdc, 0x7b, 0xda, 0xf1, 0x66, 0xe4, + 0x09, 0xe1, 0xec, 0x74, 0xfa, 0xae, 0xac, 0x46, + }, + { + 0xe3, 0x2f, 0x57, 0x01, 0xa0, 0x11, 0x5a, 0x2b, + 0x4d, 0xc7, 0x2f, 0x52, 0x6a, 0xf1, 0x61, 0x4c, + 0x59, 0x2c, 0x19, 0xee, 0x95, 0xcf, 0xcb, 0x05, + 0x35, 0x96, 0x1e, 0x07, 0x67, 0xba, 0xf7, 0x8e, + }, + { + 0xea, 0xfe, 0x80, 0x20, 0x54, 0xa4, 0x03, 0x3d, + 0x2a, 0x75, 0xec, 0x02, 0x13, 0x9a, 0x40, 0xbc, + 0x04, 0x99, 0xa5, 0x75, 0x48, 0x59, 0xab, 0xc6, + 0x8b, 0x1e, 0x54, 0x77, 0xe3, 0xd4, 0xdb, 0x4d, + }, + }, + merkleRoot: []byte{ + 0x75, 0x29, 0x58, 0x7e, 0x6a, 0xc1, 0x58, 0xc0, + 0x29, 0x1c, 0x76, 0x36, 0x27, 0x8d, 0x88, 0x5a, + 0xaa, 0x78, 0xaf, 0xb1, 0x03, 0x14, 0xae, 0x39, + 0x95, 0xec, 0x19, 0x42, 0xe0, 0xbd, 0x7a, 0x7c, + }, + }, + { + leaf: []byte{ + 0x42, 0xbb, 0xaf, 0xcd, 0xee, 0x80, 0x7b, 0xf0, + 0xe1, 0x45, 0x77, 0xe5, 0xfa, 0x6e, 0xd1, 0xbc, + 0x0c, 0xd1, 0x9b, 0xe4, 0xf7, 0x37, 0x7d, 0x31, + 0xd9, 0x0c, 0xd7, 0x00, 0x8c, 0xb7, 0x4d, 0x73, + }, + index: 8, + merkleHashes: [][]byte{ + { + 0x42, 0xbb, 0xaf, 0xcd, 0xee, 0x80, 0x7b, 0xf0, + 0xe1, 0x45, 0x77, 0xe5, 0xfa, 0x6e, 0xd1, 0xbc, + 0x0c, 0xd1, 0x9b, 0xe4, 0xf7, 0x37, 0x7d, 0x31, + 0xd9, 0x0c, 0xd7, 0x00, 0x8c, 0xb7, 0x4d, 0x73, + }, + { + 0x99, 0x1f, 0x47, 0xc5, 0xd9, 0x8c, 0x27, 0x0d, + 0xa2, 0x71, 0x5b, 0x6c, 0x39, 0xb9, 0x14, 0x2e, + 0x24, 0x2d, 0x7d, 0xbe, 0x90, 0x69, 0x34, 0x98, + 0xe5, 0x45, 0x47, 0x17, 0x47, 0x9e, 0xd5, 0x10, + }, + { + 0x9b, 0xed, 0xe8, 0x51, 0xb5, 0x83, 0x9a, 0xd6, + 0x9c, 0xad, 0x60, 0x32, 0x99, 0x20, 0x31, 0x95, + 0x87, 0x6e, 0x4e, 0x98, 0x5c, 0x27, 0x3b, 0x4f, + 0xf9, 0x8c, 0x52, 0xbe, 0x7c, 0x18, 0x9d, 0x7c, + }, + { + 0x6f, 0x97, 0x28, 0x2a, 0xb3, 0x47, 0xa2, 0x65, + 0xae, 0x33, 0x83, 0xe1, 0x56, 0x9e, 0x62, 0xda, + 0x8c, 0x19, 0xa6, 0x8c, 0xfa, 0x67, 0x0d, 0x2b, + 0x61, 0x7a, 0x7f, 0xed, 0x47, 0x44, 0xbb, 0xfe, + }, + }, + merkleRoot: []byte{ + 0x75, 0x29, 0x58, 0x7e, 0x6a, 0xc1, 0x58, 0xc0, + 0x29, 0x1c, 0x76, 0x36, 0x27, 0x8d, 0x88, 0x5a, + 0xaa, 0x78, 0xaf, 0xb1, 0x03, 0x14, 0xae, 0x39, + 0x95, 0xec, 0x19, 0x42, 0xe0, 0xbd, 0x7a, 0x7c, + }, + }, + } + for i, test := range tests { + if err := CheckMerkleChain(test.leaf, test.index, test.merkleHashes, test.merkleRoot); err != nil { + t.Errorf("Test %d - failed to validate merkle chain: %v", i, err) + } + } +} diff --git a/cmd/bfgd/bfgd.go b/cmd/bfgd/bfgd.go new file mode 100644 index 00000000..e8b8fbfc --- /dev/null +++ b/cmd/bfgd/bfgd.go @@ -0,0 +1,150 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + + "github.com/juju/loggo" + + "github.com/hemilabs/heminetwork/api/bfgapi" + "github.com/hemilabs/heminetwork/config" + "github.com/hemilabs/heminetwork/service/bfg" + "github.com/hemilabs/heminetwork/version" +) + +const ( + daemonName = "bfgd" + defaultLogLevel = daemonName + "=INFO;postgres=INFO;bfgpostgres=INFO;bfg=INFO" +) + +var ( + log = loggo.GetLogger(daemonName) + welcome = fmt.Sprintf("Hemi Bitcoin Finality Governor: v%v", version.String()) + + cfg = bfg.NewDefaultConfig() + cm = config.CfgMap{ + "BFG_EXBTC_ADDRESS": config.Config{ + Value: &cfg.EXBTCAddress, + DefaultValue: "localhost:18001", + Help: "electrumx endpoint", + Print: config.PrintAll, + }, + "BFG_PUBLIC_KEY_AUTH": config.Config{ + Value: &cfg.PublicKeyAuth, + DefaultValue: false, + Help: "enable enforcing of public key auth handshake", + Print: config.PrintAll, + }, + "BFG_BTC_START_HEIGHT": config.Config{ + Value: &cfg.BTCStartHeight, + DefaultValue: uint64(0), + Help: "bitcoin start height that serves as genesis", + Print: config.PrintAll, + Required: true, + }, + "BFG_LOG_LEVEL": config.Config{ + Value: &cfg.LogLevel, + DefaultValue: defaultLogLevel, + Help: "loglevel for various packages; INFO, DEBUG and TRACE", + Print: config.PrintAll, + }, + "BFG_POSTGRES_URI": config.Config{ + Value: &cfg.PgURI, + DefaultValue: "", + Help: "postgres connection URI", + Print: config.PrintSecret, + Required: true, + }, + "BFG_PUBLIC_ADDRESS": config.Config{ + Value: &cfg.PublicListenAddress, + DefaultValue: bfgapi.DefaultPublicListen, + Help: "address and port bfgd listens on for public, authenticated, websocket connections", + Print: config.PrintAll, + }, + "BFG_PRIVATE_ADDRESS": config.Config{ + Value: &cfg.PrivateListenAddress, + DefaultValue: bfgapi.DefaultPrivateListen, + Help: "address and port bfgd listens on for private, unauthenticated, websocket connections", + Print: config.PrintAll, + }, + "BFG_PROMETHEUS_ADDRESS": config.Config{ + Value: &cfg.PrometheusListenAddress, + DefaultValue: "", + Help: "address and port bfgd prometheus listens on", + Print: config.PrintAll, + }, + } +) + +func HandleSignals(ctx context.Context, cancel context.CancelFunc, callback func(os.Signal)) { + signalChan := make(chan os.Signal, 1) + signal.Notify(signalChan, os.Interrupt) + signal.Notify(signalChan, os.Kill) + defer func() { + signal.Stop(signalChan) + cancel() + }() + + select { + case <-ctx.Done(): + case s := <-signalChan: // First signal, cancel context. + if callback != nil { + callback(s) // Do whatever caller wants first. + cancel() + } + } + <-signalChan // Second signal, hard exit. + os.Exit(2) +} + +func _main() error { + // Parse configuration from environment + if err := config.Parse(cm); err != nil { + return err + } + + loggo.ConfigureLoggers(cfg.LogLevel) + log.Infof("%v", welcome) + + pc := config.PrintableConfig(cm) + for k := range pc { + log.Infof("%v", pc[k]) + } + + ctx, cancel := context.WithCancel(context.Background()) + go HandleSignals(ctx, cancel, func(s os.Signal) { + log.Infof("bfg service received signal: %s", s) + }) + + server, err := bfg.NewServer(cfg) + if err != nil { + return fmt.Errorf("Failed to create BFG server: %v", err) + } + if err := server.Run(ctx); err != context.Canceled { + return fmt.Errorf("BFG server terminated: %v", err) + } + + return nil +} + +func main() { + if len(os.Args) != 1 { + fmt.Fprintf(os.Stderr, "%v\n", welcome) + fmt.Fprintf(os.Stderr, "Usage:\n") + fmt.Fprintf(os.Stderr, "\thelp (this help)\n") + fmt.Fprintf(os.Stderr, "Environment:\n") + config.Help(os.Stderr, cm) + os.Exit(1) + } + + if err := _main(); err != nil { + log.Errorf("%v", err) + os.Exit(1) + } +} diff --git a/cmd/bssd/bssd.go b/cmd/bssd/bssd.go new file mode 100644 index 00000000..4e04570d --- /dev/null +++ b/cmd/bssd/bssd.go @@ -0,0 +1,125 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + + "github.com/juju/loggo" + + "github.com/hemilabs/heminetwork/api/bfgapi" + "github.com/hemilabs/heminetwork/api/bssapi" + "github.com/hemilabs/heminetwork/config" + "github.com/hemilabs/heminetwork/service/bss" + "github.com/hemilabs/heminetwork/version" +) + +const ( + daemonName = "bssd" + defaultLogLevel = daemonName + "=INFO;protocol=INFO;bss=INFO" +) + +var ( + log = loggo.GetLogger(daemonName) + welcome = fmt.Sprintf("Hemi Bitcoin Secure Sequencer: v%s", version.String()) + + cfg = bss.NewDefaultConfig() + cm = config.CfgMap{ + "BSS_BFG_URL": config.Config{ + Value: &cfg.BFGURL, + DefaultValue: bfgapi.DefaultPrivateURL, + Help: "bfgd endpoint", + Print: config.PrintAll, + }, + "BSS_ADDRESS": config.Config{ + Value: &cfg.ListenAddress, + DefaultValue: bssapi.DefaultListen, + Help: "address and port bssd listens on", + Print: config.PrintAll, + }, + "BSS_LOG_LEVEL": config.Config{ + Value: &cfg.LogLevel, + DefaultValue: defaultLogLevel, + Help: "loglevel for various packages; INFO, DEBUG and TRACE", + Print: config.PrintAll, + }, + "BSS_PROMETHEUS_ADDRESS": config.Config{ + Value: &cfg.PrometheusListenAddress, + DefaultValue: "", // bssapi.DefaultPrometheusListen, + Help: "address and port bssd prometheus listens on", + Print: config.PrintAll, + }, + } +) + +func HandleSignals(ctx context.Context, cancel context.CancelFunc, callback func(os.Signal)) { + signalChan := make(chan os.Signal, 1) + signal.Notify(signalChan, os.Interrupt) + signal.Notify(signalChan, os.Kill) + defer func() { + signal.Stop(signalChan) + cancel() + }() + + select { + case <-ctx.Done(): + case s := <-signalChan: // First signal, cancel context. + if callback != nil { + callback(s) // Do whatever caller wants first. + cancel() + } + } + <-signalChan // Second signal, hard exit. + os.Exit(2) +} + +func _main() error { + // Parse configuration from environment + if err := config.Parse(cm); err != nil { + return err + } + + loggo.ConfigureLoggers(cfg.LogLevel) + log.Infof("%v", welcome) + + pc := config.PrintableConfig(cm) + for k := range pc { + log.Infof("%v", pc[k]) + } + + ctx, cancel := context.WithCancel(context.Background()) + go HandleSignals(ctx, cancel, func(s os.Signal) { + log.Infof("bss service received signal: %s", s) + }) + + server, err := bss.NewServer(cfg) + if err != nil { + return fmt.Errorf("Failed to create BSS server: %v", err) + } + if err := server.Run(ctx); err != context.Canceled { + return fmt.Errorf("BSS server terminated with error: %v", err) + } + + return nil +} + +func main() { + if len(os.Args) != 1 { + fmt.Fprintf(os.Stderr, "%v\n", welcome) + fmt.Fprintf(os.Stderr, "Usage:\n") + fmt.Fprintf(os.Stderr, "\thelp (this help)\n") + fmt.Fprintf(os.Stderr, "Environment:\n") + config.Help(os.Stderr, cm) + os.Exit(1) + } + + if err := _main(); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } +} diff --git a/cmd/extool/extool.go b/cmd/extool/extool.go new file mode 100644 index 00000000..78edaab4 --- /dev/null +++ b/cmd/extool/extool.go @@ -0,0 +1,144 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package main + +import ( + "context" + "encoding/hex" + "flag" + "fmt" + "log" + "os" + "strconv" + "time" + + btcchainhash "github.com/btcsuite/btcd/chaincfg/chainhash" + + "github.com/hemilabs/heminetwork/hemi/electrumx" + "github.com/hemilabs/heminetwork/version" +) + +func main() { + ver := flag.Bool("v", false, "version") + flag.Parse() + if *ver { + fmt.Printf("v%v\n", version.String()) + os.Exit(0) + } + + address := flag.Arg(0) + if address == "" { + log.Fatal("No address specified") + } + + c, err := electrumx.NewClient(address) + if err != nil { + log.Fatalf("Failed to create electrumx client: %v", err) + } + + ctx, ctxCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer ctxCancel() + + switch cmd := flag.Arg(1); cmd { + case "balance": + scriptHash, err := btcchainhash.NewHashFromStr(flag.Arg(2)) + if err != nil { + log.Fatalf("Invalid script hash %q: %v", flag.Arg(2), err) + } + balance, err := c.Balance(ctx, scriptHash[:]) + if err != nil { + log.Fatalf("Failed to get balance: %v", err) + } + log.Printf("Balance for %v - confirmed %v, unconfirmed %v", scriptHash, balance.Confirmed, balance.Unconfirmed) + + case "broadcast": + rtx, err := hex.DecodeString(flag.Arg(2)) + if err != nil { + log.Fatalf("Invalid transaction %q: %v", flag.Arg(2), err) + } + txid, err := c.Broadcast(ctx, rtx) + if err != nil { + log.Fatalf("Failed to broadcast transaction: %v", err) + } + log.Printf("Transaction broadcast with hash %x", txid) + + case "raw-block-header": + height, err := strconv.Atoi(flag.Arg(2)) + if err != nil { + log.Fatalf("Invalid height %q: %v", flag.Arg(2), err) + } + rbh, err := c.RawBlockHeader(ctx, uint64(height)) + if err != nil { + log.Fatalf("Failed to get raw block header: %v", err) + } + log.Printf("%x", rbh) + + case "raw-transaction": + txHash, err := btcchainhash.NewHashFromStr(flag.Arg(2)) + if err != nil { + log.Fatalf("Invalid transaction hash %q: %v", flag.Arg(2), err) + } + rtx, err := c.RawTransaction(ctx, txHash[:]) + if err != nil { + log.Fatalf("Failed to get raw transaction: %v", err) + } + log.Printf("%x", rtx) + + case "transaction": + txHash, err := btcchainhash.NewHashFromStr(flag.Arg(2)) + if err != nil { + log.Fatalf("Invalid transaction hash %q: %v", flag.Arg(2), err) + } + txJSON, err := c.Transaction(ctx, txHash[:]) + if err != nil { + log.Fatalf("Failed to get transaction: %v", err) + } + log.Printf("%v", string(txJSON)) + + case "transaction-at-position": + height, err := strconv.Atoi(flag.Arg(2)) + if err != nil { + log.Fatalf("Invalid height %q: %v", flag.Arg(2), err) + } + index, err := strconv.Atoi(flag.Arg(3)) + if err != nil { + log.Fatalf("Invalid index %q: %v", flag.Arg(3), err) + } + txh, merkleHashes, err := c.TransactionAtPosition(ctx, uint64(height), uint64(index)) + if err != nil { + log.Fatalf("Failed to get transaction at position: %v (%T)", err, err) + } + txHash, err := btcchainhash.NewHash(txh) + if err != nil { + log.Fatalf("Failed to create BTC hash from TX hash: %v", err) + } + log.Printf("TX hash at height %v, index %v: %v", height, index, txHash) + log.Printf("Merkle hashes:") + for _, merkleHash := range merkleHashes { + log.Printf("%x", merkleHash) + } + + case "utxos": + scriptHash, err := btcchainhash.NewHashFromStr(flag.Arg(2)) + if err != nil { + log.Fatalf("Invalid script hash %q: %v", flag.Arg(2), err) + } + utxos, err := c.UTXOs(ctx, scriptHash[:]) + if err != nil { + log.Fatalf("Failed to get utxos: %v", err) + } + log.Printf("Got %d utxos for %v", len(utxos), scriptHash) + for i, utxo := range utxos { + utxoHash, err := btcchainhash.NewHash(utxo.Hash) + if err != nil { + log.Fatalf("Failed to create BTC hash from UTXO hash: %v", err) + } + log.Printf("UTXO %d - %v:%v %v", i, utxoHash, utxo.Index, utxo.Value) + } + + default: + log.Fatalf("Unknown command %q", cmd) + } +} diff --git a/cmd/hemictl/README.md b/cmd/hemictl/README.md new file mode 100644 index 00000000..77eadb06 --- /dev/null +++ b/cmd/hemictl/README.md @@ -0,0 +1,59 @@ +## hemictl + +The `hemictl` command is a generic tool to script commands to the various +daemons. The generic use is: `hemictl [json parameters]`. + +`daemon` determines the default URI `hemictl` connects to. E.g. `bss` is +`ws://localhost:8081/v1/ws`. + +TODO: Add environment variable override for the URI. + +`action` determines which command will be called. E.g. `ping`. + +`parameters` are JSON encoded parameters to the `action`. E.g. `{"timestamp":1}`. + +Thus a command to a daemon can be issues as such: +``` +hemictl bss ping '{"timestamp":1}' +``` + +Which will result in something like: +``` +{ + "origintimestamp": 1, + "timestamp": 1701091119 +} +``` + +And example of a call with a failure: +``` +hemictl bss l1tick '{"l1_height":0}' +``` + +``` +{ + "error": { + "timestamp": 1701091156, + "trace": "804d952f893e686c", + "error": "L1 tick notification with height zero" + } +} +``` + +## database + +`hemictl` allows direct access to the storage layer. For now it only supports +`postgres`. + + +``` +hemictl bfgdb version +``` +``` +{"bfgdb_version":1} +``` + +Database URI may be overridden. E.g.: +``` +LOGLEVEL=INFO PGURI="user=marco password=`cat ~/.pgsql-bfgdb-marco` database=bfgdb" ./bin/hemictl bfgdb version +``` diff --git a/cmd/hemictl/hemictl.go b/cmd/hemictl/hemictl.go new file mode 100644 index 00000000..dc6036e6 --- /dev/null +++ b/cmd/hemictl/hemictl.go @@ -0,0 +1,454 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package main + +import ( + "bytes" + "context" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "os/user" + "path/filepath" + "reflect" + "regexp" + "sort" + "strings" + "sync" + "time" + + "github.com/davecgh/go-spew/spew" + "github.com/juju/loggo" + "github.com/mitchellh/go-homedir" + + "github.com/hemilabs/heminetwork/api/bfgapi" + "github.com/hemilabs/heminetwork/api/bssapi" + "github.com/hemilabs/heminetwork/api/protocol" + "github.com/hemilabs/heminetwork/config" + "github.com/hemilabs/heminetwork/database/bfgd/postgres" + "github.com/hemilabs/heminetwork/version" +) + +const ( + daemonName = "hemictl" + defaultLogLevel = daemonName + "=INFO;bfgpostgres=INFO;postgres=INFO;protocol=INFO" +) + +var ( + log = loggo.GetLogger(daemonName) + welcome = fmt.Sprintf("Hemi Network Controller: v%v", version.String()) + + bssURL string + logLevel string + cm = config.CfgMap{ + "HEMICTL_BSS_URL": config.Config{ + Value: &bssURL, + DefaultValue: bssapi.DefaultURL, + Help: "BSS websocket server host and route", + Print: config.PrintAll, + }, + "HEMICTL_LOG_LEVEL": config.Config{ + Value: &logLevel, + DefaultValue: defaultLogLevel, + Help: "loglevel for various packages; INFO, DEBUG and TRACE", + Print: config.PrintAll, + }, + } + + callTimeout = 100 * time.Second +) + +var api string + +// handleBSSWebsocketReadUnauth discards all reads but has to exist in order to +// be able to use bssapi.Call. +func handleBSSWebsocketReadUnauth(ctx context.Context, conn *protocol.Conn) { + for { + if _, _, _, err := bssapi.ReadConn(ctx, conn); err != nil { + return + } + } +} + +// handleBSSWebsocketReadUnauth discards all reads but has to exist in order to +// be able to use bfgapi.Call. +func handleBFGWebsocketReadUnauth(ctx context.Context, conn *protocol.Conn) { + for { + if _, _, _, err := bfgapi.ReadConn(ctx, conn); err != nil { + return + } + } +} + +func bfgdb() error { + ctx, cancel := context.WithTimeout(context.Background(), callTimeout) + defer cancel() + + pgURI := os.Getenv("PGURI") // XXX mpve into config + if pgURI == "" { + // construct pgURI based on reasonable defaults. + home, err := homedir.Dir() + if err != nil { + return fmt.Errorf("Dir: %v", err) + } + user, err := user.Current() + if err != nil { + return fmt.Errorf("Current: %v", err) + } + + filename := filepath.Join(home, ".pgsql-bfgdb-"+user.Username) + password, err := os.ReadFile(filename) + if err != nil { + return fmt.Errorf("ReadFile: %v", err) + } + pgURI = fmt.Sprintf("database=bfgdb password=%s", password) + } + + db, err := postgres.New(ctx, pgURI) + if err != nil { + return fmt.Errorf("New: %v", err) + } + defer db.Close() + + param := flag.Arg(2) + c := flag.Arg(1) + out := make(map[string]any, 10) + switch c { + case "version": + out["bfgdb_version"], err = db.Version(ctx) + if err != nil { + return fmt.Errorf("error received getting version: %s", err.Error()) + } + default: + return fmt.Errorf("invalid bfgdb command: %v", c) + } + _ = param + + o, err := json.Marshal(out) + if err != nil { + return fmt.Errorf("marshal: %v", err) + } + fmt.Printf("%s\n", o) + + return nil +} + +type bssClient struct { + wg *sync.WaitGroup + bssURL string +} + +func (bsc *bssClient) handleBSSWebsocketReadUnauth(ctx context.Context, conn *protocol.Conn) { + defer bsc.wg.Done() + + log.Tracef("handleBSSWebsocketReadUnauth") + defer log.Tracef("handleBSSWebsocketReadUnauth exit") + for { + // See if we were terminated + select { + case <-ctx.Done(): + return + default: + } + + cmd, rid, payload, err := bssapi.ReadConn(ctx, conn) + if err != nil { + log.Errorf("handleBSSWebsocketReadUnauth: %v", err) + time.Sleep(3 * time.Second) + continue + // return + } + log.Infof("cmd: %v rid: %v payload: %T", cmd, rid, payload) + } +} + +func (bsc *bssClient) connect(ctx context.Context) error { + log.Tracef("connect") + defer log.Tracef("connect exit") + + conn, err := protocol.NewConn(bsc.bssURL, nil) + if err != nil { + return err + } + err = conn.Connect(ctx) + if err != nil { + return err + } + + bsc.wg.Add(1) + go bsc.handleBSSWebsocketReadUnauth(ctx, conn) + + // Required ping + // _, _, _, err = bssapi.Call(ctx, conn, bssapi.PingRequest{ + // Timestamp: time.Now().Unix(), + // }) + // if err != nil { + // return fmt.Errorf("ping error: %v", err) + // } + + simulatePingPong := false + if simulatePingPong { + bsc.wg.Add(1) + go func() { + defer bsc.wg.Done() + for { + // See if we were terminated + select { + case <-ctx.Done(): + return + default: + } + + time.Sleep(5 * time.Second) + _, _, _, err = bssapi.Call(ctx, conn, bssapi.PingRequest{ + Timestamp: time.Now().Unix(), + }) + if err != nil { + log.Errorf("ping error: %v", err) + continue + // return fmt.Errorf("ping error: %v", err) + } + } + }() + } + + // Wait for exit + bsc.wg.Wait() + + return nil +} + +func (bsc *bssClient) connectBSS(ctx context.Context) { + log.Tracef("bssClient") + defer log.Tracef("bssClient exit") + + bssURI := filepath.Join(bsc.bssURL) + log.Infof("Connecting to: %v", bssURI) + for { + if err := bsc.connect(ctx); err != nil { + // Do nothing + log.Errorf("connect: %v", err) // remove this, too loud + } + // See if we were terminated + select { + case <-ctx.Done(): + return + default: + } + + // hold off reconnect for a couple of seconds + time.Sleep(5 * time.Second) + log.Debugf("Reconnecting to: %v", bssURI) + } +} + +func bssLong(ctx context.Context) error { + bsc := &bssClient{ + wg: new(sync.WaitGroup), + bssURL: bssURL, + } + + go bsc.connectBSS(ctx) + + <-ctx.Done() + if context.Canceled != ctx.Err() && ctx.Err() != context.Canceled { + return ctx.Err() + } + + return nil +} + +func client(which string) error { + log.Debugf("client %v", which) + defer log.Debugf("client exit", which) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + simulateCtrlC := false + if simulateCtrlC { + // XXX add signal handler instead of this poop + go func() { + time.Sleep(3 * time.Second) + cancel() + }() + + defer func() { + log.Infof("waiting for exit") + time.Sleep(3 * time.Second) + }() + } + + switch which { + case "bss": + return bssLong(ctx) + } + return fmt.Errorf("invalid client: %v", which) +} + +var ( + reSkip = regexp.MustCompile(`(?i)(Response|Notification)$`) + allCommands = make(map[string]reflect.Type) + sortedCommands []string +) + +func init() { + // merge all command maps + for k, v := range bssapi.APICommands() { + allCommands[string(k)] = v + } + for k, v := range bfgapi.APICommands() { + allCommands[string(k)] = v + } + + sortedCommands = make([]string, 0, len(allCommands)) + for k := range allCommands { + sortedCommands = append(sortedCommands, k) + } + sort.Sort(sort.StringSlice(sortedCommands)) +} + +func usage() { + fmt.Fprintf(os.Stderr, "%v\n", welcome) + fmt.Fprintf(os.Stderr, "\t%v [payload]\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "Usage:\n") + fmt.Fprintf(os.Stderr, "\tbfgdb database connection\n") + fmt.Fprintf(os.Stderr, "\tbss-client long connection to bss\n") + fmt.Fprintf(os.Stderr, "\thelp (this help)\n") + fmt.Fprintf(os.Stderr, "\thelp-verbose JSON print RPC default request/response\n") + fmt.Fprintf(os.Stderr, "Environment:\n") + config.Help(os.Stderr, cm) + fmt.Fprintf(os.Stderr, "Commands:\n") + for _, v := range sortedCommands { + if reSkip.MatchString(v) { + continue + } + fmt.Fprintf(os.Stderr, "\t%v [%v]\n", v, allCommands[v]) + } +} + +func helpVerbose() { + fmt.Fprintf(os.Stderr, "%v\n", welcome) + fmt.Fprintf(os.Stderr, "\t%v [payload]\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "Payload request/response/notification:\n") + for _, v := range sortedCommands { + cmdType := allCommands[v] + clone := reflect.New(cmdType).Interface() + fmt.Fprintf(os.Stderr, "%v:\n", v) + printJSON(os.Stderr, " ", clone) + fmt.Fprintf(os.Stderr, "\n") + } +} + +func printJSON(where io.Writer, indent string, payload any) error { + w := &bytes.Buffer{} + fmt.Fprintf(where, indent) // lol first line doesnt work + e := json.NewEncoder(w) + e.SetIndent(indent, " ") + if err := e.Encode(payload); err != nil { + return fmt.Errorf("can't encode payload %T: %w", payload, err) + } + fmt.Fprintf(where, "%s", w.Bytes()) + return nil +} + +func _main() error { + if len(os.Args) < 2 { + usage() + return fmt.Errorf("not enough parameters") + } + + if err := config.Parse(cm); err != nil { + return err + } + + loggo.ConfigureLoggers(logLevel) + log.Debugf("%v", welcome) + + pc := config.PrintableConfig(cm) + for k := range pc { + log.Debugf("%v", pc[k]) + } + + cmd := flag.Arg(0) // command provided by user + + // Deal with non-generic commands + switch cmd { + case "bfgdb": + return bfgdb() + case "bss-client": + return client("bss") + case "help": + usage() + return nil + case "help-verbose": + helpVerbose() + return nil + } + + // Deal with generic commands + cmdType, ok := allCommands[cmd] + if !ok { + return fmt.Errorf("unknown command: %v", cmd) + } + // Figure out where and what we are calling based on command. + var ( + u string + callHandler func(context.Context, *protocol.Conn) + call func(context.Context, *protocol.Conn, any) (protocol.Command, string, any, error) + ) + switch { + case strings.HasPrefix(cmd, "bssapi"): + u = bssapi.DefaultURL + callHandler = handleBSSWebsocketReadUnauth + call = bssapi.Call // XXX yuck + case strings.HasPrefix(cmd, "bfgapi"): + u = bfgapi.DefaultPrivateURL + callHandler = handleBFGWebsocketReadUnauth + call = bfgapi.Call // XXX yuck + default: + return fmt.Errorf("can't derive URL from command: %v", cmd) + } + conn, err := protocol.NewConn(u, nil) + if err != nil { + return err + } + defer conn.Close() + + ctx, cancel := context.WithTimeout(context.Background(), callTimeout) + defer cancel() + go callHandler(ctx, conn) // Make sure we can use Call + + clone := reflect.New(cmdType).Interface() + log.Debugf("%v", spew.Sdump(clone)) + if flag.Arg(1) != "" { + err := json.Unmarshal([]byte(flag.Arg(1)), &clone) + if err != nil { + return fmt.Errorf("invalid payload: %w", err) + } + } + _, _, payload, err := call(ctx, conn, clone) + if err != nil { + return fmt.Errorf("%w", err) + } + log.Debugf("%v", spew.Sdump(payload)) + + return printJSON(os.Stdout, "", payload) +} + +func main() { + flag.Parse() + if len(os.Args) < 2 { + usage() + os.Exit(1) + } + + if err := _main(); err != nil { + fmt.Fprintf(os.Stderr, "\n%v: %v\n", daemonName, err) + os.Exit(1) + } +} diff --git a/cmd/keygen/keygen.go b/cmd/keygen/keygen.go new file mode 100644 index 00000000..70f3666b --- /dev/null +++ b/cmd/keygen/keygen.go @@ -0,0 +1,111 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package main + +import ( + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "os" + + "github.com/btcsuite/btcd/btcutil" + btcchaincfg "github.com/btcsuite/btcd/chaincfg" + dcrsecpk256k1 "github.com/decred/dcrd/dcrec/secp256k1/v4" + + "github.com/hemilabs/heminetwork/ethereum" + "github.com/hemilabs/heminetwork/version" +) + +var ( + net = flag.String("net", "mainnet", "Generate address of this type") + secp256k1KeyPair = flag.Bool("secp256k1", false, "Generate a secp256k1 key pair") + jsonFormat = flag.Bool("json", false, "print output as JSON") + + welcome = fmt.Sprintf("key generator: v%v", version.String()) +) + +func usage() { + fmt.Fprintf(os.Stderr, "%v\n", welcome) + fmt.Fprintf(os.Stderr, "\t%v [-net mainnet|testnet3] [-json] <-secp256k1>\n", os.Args[0]) + flag.PrintDefaults() +} + +func _main() error { + var btcChainParams *btcchaincfg.Params + switch *net { + case "testnet3", "testnet": + btcChainParams = &btcchaincfg.TestNet3Params + case "mainnet": + btcChainParams = &btcchaincfg.MainNetParams + default: + return fmt.Errorf("invalid net: %v", *net) + } + + switch { + case *secp256k1KeyPair: + privKey, err := dcrsecpk256k1.GeneratePrivateKey() + if err != nil { + return fmt.Errorf("Failed to generate secp256k1 private key: %v", err) + } + btcAddress, err := btcutil.NewAddressPubKey(privKey.PubKey().SerializeCompressed(), + btcChainParams) + if err != nil { + return fmt.Errorf("failed to create BTC address from public key: %v", + err) + } + hash := btcAddress.AddressPubKeyHash().String() + ethAddress := ethereum.AddressFromPrivateKey(privKey) + if *jsonFormat { + type Secp256k1 struct { + EthereumAddress string `json:"ethereum_address"` + Network string `json:"network"` + PrivateKey string `json:"private_key"` + PublicKey string `json:"public_key"` + PubkeyHash string `json:"pubkey_hash"` + } + s := &Secp256k1{ + EthereumAddress: ethAddress.String(), + Network: *net, + PrivateKey: hex.EncodeToString(privKey.Serialize()), + PublicKey: hex.EncodeToString(privKey.PubKey().SerializeCompressed()), + PubkeyHash: hash, + } + js, err := json.MarshalIndent(s, "", " ") + if err != nil { + return fmt.Errorf("marshal: %v", err) + } + fmt.Printf("%s\n", js) + } else { + fmt.Printf("eth address: %v\n", ethAddress) + fmt.Printf("network : %v\n", *net) + fmt.Printf("private key: %x\n", privKey.Serialize()) + fmt.Printf("public key : %x\n", privKey.PubKey().SerializeCompressed()) + fmt.Printf("pubkey hash: %v\n", hash) + + } + + default: + usage() + return fmt.Errorf("invalid flag") + } + + return nil +} + +func main() { + ver := flag.Bool("v", false, "version") + flag.Parse() + if *ver { + fmt.Printf("v%v\n", version.String()) + os.Exit(0) + } + flag.Parse() + + if err := _main(); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } +} diff --git a/cmd/popmd/popmd.go b/cmd/popmd/popmd.go new file mode 100644 index 00000000..32c5d300 --- /dev/null +++ b/cmd/popmd/popmd.go @@ -0,0 +1,129 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + + "github.com/juju/loggo" + + "github.com/hemilabs/heminetwork/config" + "github.com/hemilabs/heminetwork/service/popm" + "github.com/hemilabs/heminetwork/version" +) + +const ( + daemonName = "popmd" + defaultLogLevel = daemonName + "=INFO;popm=INFO" +) + +var ( + log = loggo.GetLogger(daemonName) + welcome = fmt.Sprintf("Hemi Proof of Proof miner: v%v", version.String()) + + cfg = popm.NewDefaultConfig() + cm = config.CfgMap{ + "POPM_LOG_LEVEL": config.Config{ + Value: &cfg.LogLevel, + DefaultValue: defaultLogLevel, + Help: "loglevel for various packages; INFO, DEBUG and TRACE", + Print: config.PrintAll, + }, + "POPM_BTC_PRIVKEY": config.Config{ + Value: &cfg.BTCPrivateKey, + Required: true, + DefaultValue: "", + Help: "bitcoin private key", + Print: config.PrintSecret, + }, + "POPM_BFG_URL": config.Config{ + Value: &cfg.BFGWSURL, + DefaultValue: popm.NewDefaultConfig().BFGWSURL, + Help: "url for BFG (Bitcoin Finality Governor)", + Print: config.PrintAll, + }, + "POPM_BTC_CHAIN_NAME": config.Config{ + Value: &cfg.BTCChainName, + DefaultValue: popm.NewDefaultConfig().BTCChainName, + Help: "the name of the bitcoing chain to connect to (ex. \"mainnet\", \"testnet3\")", + Print: config.PrintAll, + }, + "POPM_PROMETHEUS_ADDRESS": config.Config{ + Value: &cfg.PrometheusListenAddress, + DefaultValue: "", + Help: "address and port bssd prometheus listens on", + Print: config.PrintAll, + }, + } +) + +func handleSignals(ctx context.Context, cancel context.CancelFunc, callback func(os.Signal)) { + signalChan := make(chan os.Signal, 1) + signal.Notify(signalChan, os.Interrupt) + signal.Notify(signalChan, os.Kill) + defer func() { + signal.Stop(signalChan) + cancel() + }() + + select { + case <-ctx.Done(): + case s := <-signalChan: // First signal, cancel context. + if callback != nil { + callback(s) // Do whatever caller wants first. + cancel() + } + } + <-signalChan // Second signal, hard exit. + os.Exit(2) +} + +func _main() error { + if err := config.Parse(cm); err != nil { + return err + } + + loggo.ConfigureLoggers(cfg.LogLevel) + log.Infof("%v", welcome) + + pc := config.PrintableConfig(cm) + for k := range pc { + log.Infof("%v", pc[k]) + } + + ctx, cancel := context.WithCancel(context.Background()) + go handleSignals(ctx, cancel, func(s os.Signal) { + log.Infof("popm service received signal: %s", s) + }) + + miner, err := popm.NewMiner(cfg) + if err != nil { + return fmt.Errorf("Failed to create POP miner: %v", err) + } + if err := miner.Run(ctx); err != context.Canceled { + return fmt.Errorf("POP miner terminated: %v", err) + } + + return nil +} + +func main() { + if len(os.Args) != 1 { + fmt.Fprintf(os.Stderr, "%v\n", welcome) + fmt.Fprintf(os.Stderr, "Usage:\n") + fmt.Fprintf(os.Stderr, "\thelp (this help)\n") + fmt.Fprintf(os.Stderr, "Environment:\n") + config.Help(os.Stderr, cm) + os.Exit(1) + } + + if err := _main(); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + os.Exit(1) + } +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 00000000..ccf145ac --- /dev/null +++ b/config/config.go @@ -0,0 +1,149 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package config + +import ( + "fmt" + "io" + "os" + "reflect" + "sort" + "strconv" +) + +type PrintMode int + +const ( + PrintSecret PrintMode = iota + PrintAll + PrintNothing +) + +var Align = 0 // Cleartext alignment, if not set it is autodetected + +type Config struct { + Value any // Value + DefaultValue any // Default value if Value is not set + Help string // One line help + Print PrintMode // Print mode + Required bool // If true, error out with error +} + +type CfgMap map[string]Config + +func Parse(c CfgMap) error { + for k, v := range c { + // Make sure v.Value is a pointer + if reflect.TypeOf(v.Value).Kind() != reflect.Pointer { + return fmt.Errorf("Value must be a pointer") + } + // Make sure we are pointing to the same type + if reflect.TypeOf(v.Value).Elem() != reflect.TypeOf(v.DefaultValue) { + return fmt.Errorf("Value not the same type as DefaultValue, "+ + "wanted %v got %v", reflect.TypeOf(v.Value).Elem(), + reflect.TypeOf(v.DefaultValue)) + } + + envValue := os.Getenv(k) + if envValue == "" { + // Error out if this is not provided + if v.Required { + return fmt.Errorf("%v: must be set", k) + } + + // Set v.Value to v.DefaultValue + reflect.ValueOf(v.Value).Elem().Set(reflect.ValueOf(v.DefaultValue)) + } else { + switch reflect.TypeOf(v.Value).Elem().Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, + reflect.Int32, reflect.Int64: + + evTyped, err := strconv.ParseInt(envValue, 10, 64) + if err != nil { + return fmt.Errorf("invalid integer for %v: %v", + k, err) + } + reflect.ValueOf(v.Value).Elem().SetInt(evTyped) + + case reflect.Uint, reflect.Uint8, reflect.Uint16, + reflect.Uint32, reflect.Uint64: + + evTyped, err := strconv.ParseUint(envValue, 10, 64) + if err != nil { + return fmt.Errorf("invalid unsigned for %v: %v", + k, err) + } + reflect.ValueOf(v.Value).Elem().SetUint(evTyped) + + case reflect.String: + reflect.ValueOf(v.Value).Elem().SetString(envValue) + + case reflect.Bool: + val, err := strconv.ParseBool(envValue) + if err != nil { + return err + } + + reflect.ValueOf(v.Value).Elem().SetBool(val) + + default: + return fmt.Errorf("unsuported type for %v: %v", + k, reflect.TypeOf(v.Value).Elem().Kind()) + } + } + } + + return nil +} + +func PrintableConfig(c CfgMap) []string { + keys := make([]string, 0, len(c)) + for k := range c { + keys = append(keys, k) + if Align < len(k) { + Align = len(k) + } + } + sort.Strings(keys) + + p := make([]string, 0, len(c)) + for k := range keys { + key := keys[k] + + switch c[key].Print { + case PrintAll: + val := reflect.ValueOf(c[key].Value).Elem() + p = append(p, fmt.Sprintf("%-*s: %v", Align, key, val)) + case PrintSecret: + p = append(p, fmt.Sprintf("%-*s: %v", Align, key, "********")) + } + } + return p +} + +func Help(w io.Writer, c CfgMap) { + keys := make([]string, 0, len(c)) + for k := range c { + keys = append(keys, k) + if Align < len(k) { + Align = len(k) + } + } + sort.Strings(keys) + + for k := range keys { + key := keys[k] + required := "" + if c[key].Required { + required = "(required) " + } + def := "" + if c[key].DefaultValue != "" { + def = fmt.Sprintf("(default: %v)", c[key].DefaultValue) + } + fmt.Fprintf(w, "\t%-*s: %v %v%v\n", + Align, key, c[key].Help, required, def) + } +} diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 00000000..58eab0db --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,81 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package config + +import ( + "os" + "testing" +) + +type MyConfig struct { + IamString string + IamRequiredString string + IamUint64 uint64 + IamInt32 int32 +} + +var ( + cfg = MyConfig{} + cm CfgMap = CfgMap{ + "STRING": Config{ + Value: &cfg.IamString, + DefaultValue: "default", + Help: "helpstring", + Print: PrintAll, + }, + "UINT64": Config{ + Value: &cfg.IamUint64, + DefaultValue: uint64(1234), + Help: "helpuint64", + Print: PrintAll, + }, + "INT32": Config{ + Value: &cfg.IamInt32, + DefaultValue: int32(4321), + Help: "helpint32", + Print: PrintAll, + }, + } +) + +func TestConfigTypesDefault(t *testing.T) { + err := Parse(cm) + if err != nil { + t.Fatal(err) + } +} + +func TestConfigTypesRequired(t *testing.T) { + cmr := make(CfgMap) + for k, v := range cm { + v.Required = true + cmr[k] = v + } + err := Parse(cmr) + if err == nil { + t.Fatal("expected failure, got nil") + } + + // Set env + for k, v := range cmr { + switch v.DefaultValue.(type) { + case string: + if err := os.Setenv(k, "ENVSTRING"); err != nil { + t.Fatal(err) + } + + case int32, uint64: + if err := os.Setenv(k, "31337"); err != nil { + t.Fatal(err) + } + } + v.Required = true + cmr[k] = v + } + err = Parse(cmr) + if err != nil { + t.Fatal(err) + } +} diff --git a/database/bfgd/TESTS.md b/database/bfgd/TESTS.md new file mode 100644 index 00000000..648303f3 --- /dev/null +++ b/database/bfgd/TESTS.md @@ -0,0 +1,11 @@ +## Running extended tests + +Create a user that has CREATEDB privilege. +``` +sudo -u postgres psql -c "CREATE ROLE bfgdtest WITH LOGIN PASSWORD 'password' NOSUPERUSER CREATEDB;" +``` + +run tests: +``` +PGTESTURI="postgres://bfgdtest:password@localhost/postgres" go test -v ./... +``` diff --git a/database/bfgd/database.go b/database/bfgd/database.go new file mode 100644 index 00000000..65464be5 --- /dev/null +++ b/database/bfgd/database.go @@ -0,0 +1,142 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package bfgd + +import ( + "context" + + "github.com/hemilabs/heminetwork/database" +) + +type Database interface { + database.Database + + // Version table + Version(ctx context.Context) (int, error) + + // L2 keystone table + L2KeystonesInsert(ctx context.Context, l2ks []L2Keystone) error + L2KeystonesCount(ctx context.Context) (int, error) + L2KeystoneByAbrevHash(ctx context.Context, aHash [32]byte) (*L2Keystone, error) + L2KeystonesMostRecentN(ctx context.Context, n uint32) ([]L2Keystone, error) + + // Btc block table + BtcBlockInsert(ctx context.Context, bb *BtcBlock) error + BtcBlockByHash(ctx context.Context, hash [32]byte) (*BtcBlock, error) + BtcBlockHeightByHash(ctx context.Context, hash [32]byte) (uint64, error) + + // Pop data + PopBasisByL2KeystoneAbrevHash(ctx context.Context, aHash [32]byte, excludeUnconfirmed bool) ([]PopBasis, error) + PopBasisInsertFull(ctx context.Context, pb *PopBasis) error + PopBasisInsertPopMFields(ctx context.Context, pb *PopBasis) error + PopBasisUpdateBTCFields(ctx context.Context, pb *PopBasis) (int64, error) + + L2BTCFinalityMostRecent(ctx context.Context, limit uint32) ([]L2BTCFinality, error) + L2BTCFinalityByL2KeystoneAbrevHash(ctx context.Context, l2KeystoneAbrevHashes []database.ByteArray) ([]L2BTCFinality, error) + + BtcBlockCanonicalHeight(ctx context.Context) (uint64, error) + + AccessPublicKeyInsert(ctx context.Context, publicKey *AccessPublicKey) error + AccessPublicKeyExists(ctx context.Context, publicKey *AccessPublicKey) (bool, error) + AccessPublicKeyDelete(ctx context.Context, publicKey *AccessPublicKey) error +} + +// NotificationName identifies a database notification type. +const ( + NotificationBtcBlocks database.NotificationName = "btc_blocks" + NotificationAccessPublicKeyDelete database.NotificationName = "access_public_keys" + NotificationL2Keystones database.NotificationName = "l2_keystones" +) + +// NotificationPayload returns the data structure corresponding to the given +// notification type. +func NotificationPayload(ntfn database.NotificationName) (any, bool) { + payload, ok := notifications[ntfn] + return payload, ok +} + +// notifications specifies the mapping between a notification type and its +// data structure. +var notifications = map[database.NotificationName]any{ + NotificationBtcBlocks: BtcBlock{}, + NotificationAccessPublicKeyDelete: AccessPublicKey{}, + NotificationL2Keystones: []L2Keystone{}, +} + +// we use the `deep:"-"` tag to ignore checking for these +// values in tests with deep.Equal. since the database +// generates these values, there is no way to guarantee +// their value from Go. in the future we can tests that these +// values are between Go values, but for now ignore + +type L2Keystone struct { + Hash database.ByteArray // lookup key + Version uint32 + L1BlockNumber uint32 + L2BlockNumber uint32 + ParentEPHash database.ByteArray + PrevKeystoneEPHash database.ByteArray + StateRoot database.ByteArray + EPHash database.ByteArray + CreatedAt database.Timestamp `deep:"-"` + UpdatedAt database.Timestamp `deep:"-"` +} + +type BtcBlock struct { + Hash database.ByteArray `json:"hash"` + Header database.ByteArray `json:"header"` + Height uint64 `json:"height"` + CreatedAt database.Timestamp `deep:"-"` + UpdatedAt database.Timestamp `deep:"-"` +} + +type PopBasis struct { + ID uint64 `deep:"-"` + BtcTxId database.ByteArray + BtcRawTx database.ByteArray + BtcHeaderHash database.ByteArray + BtcTxIndex *uint64 + BtcMerklePath []string + PopTxId database.ByteArray + PopMinerPublicKey database.ByteArray + L2KeystoneAbrevHash database.ByteArray + CreatedAt database.Timestamp `deep:"-"` + UpdatedAt database.Timestamp `deep:"-"` +} + +type L2BTCFinality struct { + L2Keystone L2Keystone + BTCPubHeight int64 + BTCPubHeaderHash database.ByteArray + EffectiveHeight uint32 + BTCTipHeight uint32 +} + +// XXX this needs to be generic +type Notification struct { + ID string +} + +type AccessPublicKey struct { + PublicKey []byte + CreatedAt database.Timestamp `deep:"-"` + + // this is a hack to pull the public key from db notifications, + // since it comes back as an encoded string + PublicKeyEncoded string `json:"public_key" deep:"-"` +} + +const ( + IdentifierBTCNewBlock = "btc-new-block" + IdentifierBTCFinality = "btc-finality" +) + +var BTCNewBlockNotification = Notification{ + ID: IdentifierBTCNewBlock, +} + +var BTCFinalityNotification = Notification{ + ID: IdentifierBTCFinality, +} diff --git a/database/bfgd/database_ext_test.go b/database/bfgd/database_ext_test.go new file mode 100644 index 00000000..19d1c705 --- /dev/null +++ b/database/bfgd/database_ext_test.go @@ -0,0 +1,1803 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package bfgd_test + +import ( + "context" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "io/ioutil" + "math/rand" + "net/url" + "os" + "path/filepath" + "reflect" + "slices" + "sort" + "testing" + "time" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/davecgh/go-spew/spew" + "github.com/go-test/deep" + + "github.com/hemilabs/heminetwork/database" + "github.com/hemilabs/heminetwork/database/bfgd" + "github.com/hemilabs/heminetwork/database/bfgd/postgres" +) + +const testDBPrefix = "bfgdtestdb" + +func createTestDB(ctx context.Context, t *testing.T) (bfgd.Database, *sql.DB, func()) { + t.Helper() + + pgURI := os.Getenv("PGTESTURI") + if pgURI == "" { + t.Skip("PGTESTURI environment variable is not set, skipping...") + } + + var ( + cleanup func() + ddb, sdb *sql.DB + needCleanup = true + ) + defer func() { + if !needCleanup { + return + } + if sdb != nil { + sdb.Close() + } + if cleanup != nil { + cleanup() + } + if ddb != nil { + ddb.Close() + } + }() + + ddb, err := postgres.Connect(ctx, pgURI) + if err != nil { + t.Fatalf("Failed to connect to database: %v", err) + } + + dbn := rand.New(rand.NewSource(time.Now().UnixNano())).Intn(9999) + dbName := fmt.Sprintf("%v_%d", testDBPrefix, dbn) + + t.Logf("Creating test database %v", dbName) + + qCreateDB := fmt.Sprintf("CREATE DATABASE %v", dbName) + if _, err := ddb.ExecContext(ctx, qCreateDB); err != nil { + t.Fatalf("Failed to create test database: %v", err) + } + + cleanup = func() { + t.Logf("Removing test database %v", dbName) + qDropDB := fmt.Sprintf("DROP DATABASE %v", dbName) + if _, err := ddb.ExecContext(ctx, qDropDB); err != nil { + t.Fatalf("Failed to drop test database: %v", err) + } + ddb.Close() + } + + u, err := url.Parse(pgURI) + if err != nil { + t.Fatalf("Failed to parse postgresql URI: %v", err) + } + u.Path = dbName + + sdb, err = postgres.Connect(ctx, u.String()) + if err != nil { + t.Fatalf("Failed to connect to database: %v", err) + } + + // Load schema. + wd, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + applySQLFiles(ctx, t, sdb, filepath.Join(wd, "./scripts/*.sql")) + + db, err := postgres.New(ctx, u.String()) + if err != nil { + t.Fatalf("Failed to connect to database: %v", err) + } + + needCleanup = false + + return db, sdb, cleanup +} + +func applySQLFiles(ctx context.Context, t *testing.T, sdb *sql.DB, path string) { + t.Helper() + + sqlFiles, err := filepath.Glob(path) + if err != nil { + t.Fatalf("Failed to get schema files: %v", err) + } + sort.Strings(sqlFiles) + for _, sqlFile := range sqlFiles { + t.Logf("Applying SQL file %v", filepath.Base(sqlFile)) + sql, err := ioutil.ReadFile(sqlFile) + if err != nil { + t.Fatalf("Failed to read SQL file: %v", err) + } + if _, err := sdb.ExecContext(ctx, string(sql)); err != nil { + t.Fatalf("Failed to execute SQL: %v", err) + } + } +} + +func TestDatabaseTestData(t *testing.T) { + ctx := context.Background() + + db, sdb, cleanup := createTestDB(ctx, t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + // Load test data. + wd, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + applySQLFiles(ctx, t, sdb, filepath.Join(wd, "./scripts/testdata/*.sql")) +} + +func TestDatabasePostgres(t *testing.T) { + ctx := context.Background() + + db, sdb, cleanup := createTestDB(ctx, t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + // Version, no need to verify because it is explicitely tested on db open + version, err := db.Version(ctx) + if err != nil { + t.Fatalf("Failed to get Version: %v", err) + } + t.Logf("db version: %v", version) + + // L2KeystonesInsert + b1 := [32]byte{1} + b1Hash := chainhash.DoubleHashB(b1[:]) + ks1 := bfgd.L2Keystone{ + Hash: b1Hash, + Version: 1, + L1BlockNumber: 1, + L2BlockNumber: 1, + ParentEPHash: b1Hash, + PrevKeystoneEPHash: b1Hash, + StateRoot: b1Hash, + EPHash: b1Hash, + } + b2 := [32]byte{2} + b2Hash := chainhash.DoubleHashB(b2[:]) + ks2 := bfgd.L2Keystone{ + Hash: b2Hash, + Version: 1, + L1BlockNumber: 2, + L2BlockNumber: 2, + ParentEPHash: b2Hash, + PrevKeystoneEPHash: b2Hash, + StateRoot: b2Hash, + EPHash: b2Hash, + } + l2ksIn := []bfgd.L2Keystone{ks1, ks2} + err = db.L2KeystonesInsert(ctx, l2ksIn) + if err != nil { + t.Fatalf("Failed to get L2 keystones: %v", err) + } + // Get keystones back out + for k := range l2ksIn { + l2ksOut, err := db.L2KeystoneByAbrevHash(ctx, [32]byte(l2ksIn[k].Hash)) + if err != nil { + t.Fatalf("Failed to get L2 keystones: %v", err) + } + diff := deep.Equal(*l2ksOut, l2ksIn[k]) + if len(diff) > 0 { + t.Fatalf("Failed %v to verify keystone got %v, wanted %v%v", + k, spew.Sdump(*l2ksOut), spew.Sdump(l2ksIn[k]), diff) + } + } + // Most recent + l2ksOut, err := db.L2KeystonesMostRecentN(ctx, 1) + if err != nil { + t.Fatalf("Failed to get most recent L2 keystone: %v", err) + } + diff := deep.Equal(l2ksOut[0], l2ksIn[1]) + if len(diff) > 0 { + t.Fatalf("Failed to verify most recent keystone got %v, wanted %v%v", + spew.Sdump(l2ksOut[0]), spew.Sdump(l2ksIn[1]), diff) + } + + // Insert BTC block + bb1Header := [80]byte{1} + h := chainhash.DoubleHashB(bb1Header[:]) + var bb1Hash [32]byte + copy(bb1Hash[:], h) + bb1In := bfgd.BtcBlock{ + Hash: database.ByteArray(bb1Hash[:]), + Header: database.ByteArray(bb1Header[:]), + Height: 1, + } + err = db.BtcBlockInsert(ctx, &bb1In) + if err != nil { + t.Fatalf("Failed to insert bitcoin block: %v", err) + } + // Get BTC block + bb1Out, err := db.BtcBlockByHash(ctx, bb1Hash) + if err != nil { + t.Fatalf("Failed to get bitcoin block: %v", err) + } + diff = deep.Equal(*bb1Out, bb1In) + if len(diff) > 0 { + t.Fatalf("Failed to get bitcoin block 1 got %v, want %v%v", + spew.Sdump(*bb1Out), spew.Sdump(bb1In), diff) + } + // Get BTC block height + height, err := db.BtcBlockHeightByHash(ctx, bb1Hash) + if err != nil { + t.Fatalf("Failed to get bitcoin block height: %v", err) + } + if height != bb1In.Height { + t.Fatalf("Failed to get bitcoin block height got %v, want %v", + height, bb1In.Height) + } + + // Pop basis insert half + btcTx := [285]byte{1} + btcTxHash := chainhash.DoubleHashB(btcTx[:]) + popMinerPublicKey := [63]byte{'1', '2', '3', '4'} + h = chainhash.DoubleHashB(popMinerPublicKey[:]) + var l2KAH [32]byte + copy(l2KAH[:], h) + pbHalfIn := bfgd.PopBasis{ + BtcTxId: btcTxHash, + BtcRawTx: btcTx[:], + BtcHeaderHash: bb1Hash[:], + PopMinerPublicKey: popMinerPublicKey[:], + L2KeystoneAbrevHash: l2KAH[:], + } + err = db.PopBasisInsertFull(ctx, &pbHalfIn) + if err != nil { + t.Fatalf("Failed to insert pop basis: %v", err) + } + + // Pop basis get half + pbHalfOut, err := db.PopBasisByL2KeystoneAbrevHash(ctx, l2KAH, true) + if err != nil { + t.Fatalf("Failed to get pop basis: %v", err) + } + diff = deep.Equal(pbHalfOut[0], pbHalfIn) + if len(diff) > 0 { + t.Fatalf("Failed to get l2 half got %v, want %v%v", + spew.Sdump(pbHalfOut[0]), spew.Sdump(pbHalfIn), diff) + } +} + +// XXX make this generic and table driven for all notifications +type btcBlocksNtfn struct { + t *testing.T + + cancel context.CancelFunc + + // add expect + expected *bfgd.BtcBlock +} + +func (b *btcBlocksNtfn) handleBtcBlocksNotification(table string, action string, payload, payloadOld interface{}) { + defer b.cancel() + bb := payload.(*bfgd.BtcBlock) + if !reflect.DeepEqual(*b.expected, *bb) { + b.t.Fatalf("expected %v, got %v", + spew.Sdump(*b.expected), spew.Sdump(*bb)) + } +} + +func TestDatabaseNotification(t *testing.T) { + pctx := context.Background() + + db, sdb, cleanup := createTestDB(pctx, t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + btcBlock := &bfgd.BtcBlock{ + Hash: fillOutBytes("MyHaSh", 32), + Header: fillOutBytes("myHeAdEr", 80), + Height: 1, + } + // Register notfication + ctx, cancel := context.WithTimeout(pctx, 5*time.Second) + defer cancel() + b := &btcBlocksNtfn{ + t: t, + cancel: cancel, + expected: btcBlock, + } + payload, _ := bfgd.NotificationPayload(bfgd.NotificationBtcBlocks) + if err := db.RegisterNotification(ctx, bfgd.NotificationBtcBlocks, + b.handleBtcBlocksNotification, payload); err != nil { + t.Fatalf("register notification: %v", err) + } + + err := db.BtcBlockInsert(ctx, btcBlock) + if err != nil { + t.Fatalf("Failed to insert bitcoin block: %v", err) + } + + // Wait for completion or timeout + <-ctx.Done() + if ctx.Err() != context.Canceled { + t.Fatal(ctx.Err()) + } +} + +func defaultTestContext() (context.Context, func()) { + return context.WithTimeout(context.Background(), 300*time.Second) +} + +// fillOutBytes will take a string and return a slice of bytes +// with values from the string suffixed until a size with bytes '_' +func fillOutBytes(prefix string, size int) []byte { + result := []byte(prefix) + for len(result) < size { + result = append(result, '_') + } + + return result +} + +func TestL2KeystoneInsertSuccess(t *testing.T) { + ctx, cancel := defaultTestContext() + defer cancel() + + db, sdb, cleanup := createTestDB(ctx, t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + l2Keystone := bfgd.L2Keystone{ + Version: 1, + L1BlockNumber: 11, + L2BlockNumber: 22, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + Hash: fillOutBytes("mockhash", 32), + } + + err := db.L2KeystonesInsert(ctx, []bfgd.L2Keystone{l2Keystone}) + if err != nil { + t.Fatal(err) + } + + saved, err := db.L2KeystoneByAbrevHash(ctx, [32]byte(l2Keystone.Hash)) + if err != nil { + t.Fatal(err) + } + + diff := deep.Equal(saved, &l2Keystone) + if len(diff) != 0 { + t.Fatalf("unexpected diff %s", diff) + } + + count, err := db.L2KeystonesCount(ctx) + if err != nil { + t.Fatal(err) + } + + if count != 1 { + t.Fatalf("unexpected count %d", count) + } +} + +func TestL2KeystoneInsertMultipleSuccess(t *testing.T) { + ctx, cancel := defaultTestContext() + defer cancel() + + db, sdb, cleanup := createTestDB(ctx, t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + l2Keystone := bfgd.L2Keystone{ + Version: 1, + L1BlockNumber: 11, + L2BlockNumber: 22, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + Hash: fillOutBytes("mockhash", 32), + } + + otherL2Keystone := bfgd.L2Keystone{ + Version: 1, + L1BlockNumber: 11, + L2BlockNumber: 22, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + Hash: fillOutBytes("mockhashz", 32), + } + + err := db.L2KeystonesInsert(ctx, []bfgd.L2Keystone{l2Keystone, otherL2Keystone}) + if err != nil { + t.Fatal(err) + } + + saved, err := db.L2KeystoneByAbrevHash(ctx, [32]byte(l2Keystone.Hash)) + if err != nil { + t.Fatal(err) + } + + diff := deep.Equal(saved, &l2Keystone) + if len(diff) != 0 { + t.Fatalf("unexpected diff %s", diff) + } + + otherSaved, err := db.L2KeystoneByAbrevHash(ctx, [32]byte(otherL2Keystone.Hash)) + if err != nil { + t.Fatal(err) + } + + diff = deep.Equal(otherSaved, &otherL2Keystone) + if len(diff) != 0 { + t.Fatalf("unexpected diff %s", diff) + } + + count, err := db.L2KeystonesCount(ctx) + if err != nil { + t.Fatal(err) + } + + if count != 2 { + t.Fatalf("unexpected count %d", count) + } +} + +func TestL2KeystoneInsertInvalidHashLength(t *testing.T) { + ctx, cancel := defaultTestContext() + defer cancel() + + db, sdb, cleanup := createTestDB(ctx, t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + l2Keystone := bfgd.L2Keystone{ + Version: 1, + L1BlockNumber: 11, + L2BlockNumber: 22, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + Hash: fillOutBytes("mockhash", 31), + } + + err := db.L2KeystonesInsert(ctx, []bfgd.L2Keystone{l2Keystone}) + if err == nil || errors.Is(database.ValidationError(""), err) == false { + t.Fatalf("unexpected error %s", err) + } + + count, err := db.L2KeystonesCount(ctx) + if err != nil { + t.Fatal(err) + } + + if count != 0 { + t.Fatalf("unexpected count %d", count) + } +} + +func TestL2KeystoneInsertInvalidEPHashLength(t *testing.T) { + ctx, cancel := defaultTestContext() + defer cancel() + + db, sdb, cleanup := createTestDB(ctx, t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + l2Keystone := bfgd.L2Keystone{ + Version: 1, + L1BlockNumber: 11, + L2BlockNumber: 22, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 31), + Hash: fillOutBytes("mockhash", 32), + } + + err := db.L2KeystonesInsert(ctx, []bfgd.L2Keystone{l2Keystone}) + if err == nil || errors.Is(database.ValidationError(""), err) == false { + t.Fatalf("unexpected error %s", err) + } + + count, err := db.L2KeystonesCount(ctx) + if err != nil { + t.Fatal(err) + } + + if count != 0 { + t.Fatalf("unexpected count %d", count) + } +} + +func TestL2KeystoneInsertInvalidStateRootLength(t *testing.T) { + ctx, cancel := defaultTestContext() + defer cancel() + + db, sdb, cleanup := createTestDB(ctx, t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + l2Keystone := bfgd.L2Keystone{ + Version: 1, + L1BlockNumber: 11, + L2BlockNumber: 22, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 31), + EPHash: fillOutBytes("ephash", 32), + Hash: fillOutBytes("mockhash", 32), + } + + err := db.L2KeystonesInsert(ctx, []bfgd.L2Keystone{l2Keystone}) + if err == nil || errors.Is(database.ValidationError(""), err) == false { + t.Fatalf("unexpected error %s", err) + } + + count, err := db.L2KeystonesCount(ctx) + if err != nil { + t.Fatal(err) + } + + if count != 0 { + t.Fatalf("unexpected count %d", count) + } +} + +func TestL2KeystoneInsertInvalidPrevKeystoneEPHashLength(t *testing.T) { + ctx, cancel := defaultTestContext() + defer cancel() + + db, sdb, cleanup := createTestDB(ctx, t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + l2Keystone := bfgd.L2Keystone{ + Version: 1, + L1BlockNumber: 11, + L2BlockNumber: 22, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 31), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + Hash: fillOutBytes("mockhash", 32), + } + + err := db.L2KeystonesInsert(ctx, []bfgd.L2Keystone{l2Keystone}) + if err == nil || errors.Is(database.ValidationError(""), err) == false { + t.Fatalf("unexpected error %s", err) + } + + count, err := db.L2KeystonesCount(ctx) + if err != nil { + t.Fatal(err) + } + + if count != 0 { + t.Fatalf("unexpected count %d", count) + } +} + +func TestL2KeystoneInsertInvalidParentEPHashLength(t *testing.T) { + ctx, cancel := defaultTestContext() + defer cancel() + + db, sdb, cleanup := createTestDB(ctx, t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + l2Keystone := bfgd.L2Keystone{ + Version: 1, + L1BlockNumber: 11, + L2BlockNumber: 22, + ParentEPHash: fillOutBytes("parentephash", 31), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + Hash: fillOutBytes("mockhash", 32), + } + + err := db.L2KeystonesInsert(ctx, []bfgd.L2Keystone{l2Keystone}) + if err == nil || errors.Is(database.ValidationError(""), err) == false { + t.Fatalf("unexpected error %s", err) + } + + count, err := db.L2KeystonesCount(ctx) + if err != nil { + t.Fatal(err) + } + + if count != 0 { + t.Fatalf("unexpected count %d", count) + } +} + +func TestL2KeystoneByAbrevHashNotFound(t *testing.T) { + ctx, cancel := defaultTestContext() + defer cancel() + + db, sdb, cleanup := createTestDB(ctx, t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + _, err := db.L2KeystoneByAbrevHash(ctx, [32]byte(fillOutBytes("doesnotexist", 32))) + if err == nil || errors.Is(err, database.NotFoundError("")) == false { + t.Fatalf("unexpected error %s", err) + } +} + +func TestL2KeystoneByAbrevHashFound(t *testing.T) { + ctx, cancel := defaultTestContext() + defer cancel() + + db, sdb, cleanup := createTestDB(ctx, t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + l2Keystone := bfgd.L2Keystone{ + Version: 1, + L1BlockNumber: 11, + L2BlockNumber: 22, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + Hash: fillOutBytes("mockhash", 32), + } + + err := db.L2KeystonesInsert(ctx, []bfgd.L2Keystone{l2Keystone}) + if err != nil { + t.Fatalf("unexpected error %s", err) + } + + l2KeystoneSaved, err := db.L2KeystoneByAbrevHash(ctx, [32]byte(l2Keystone.Hash)) + if err != nil { + t.Fatalf("unexpected error %s", err) + } + + diff := deep.Equal(l2KeystoneSaved, &l2Keystone) + if len(diff) != 0 { + t.Fatalf("unexpected diff %s", diff) + } +} + +func TestL2KeystoneInsertMostRecentNMoreThanSaved(t *testing.T) { + ctx, cancel := defaultTestContext() + defer cancel() + + db, sdb, cleanup := createTestDB(ctx, t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + l2Keystone := bfgd.L2Keystone{ + Version: 1, + L1BlockNumber: 11, + L2BlockNumber: 22, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + Hash: fillOutBytes("mockhash", 32), + } + + otherL2Keystone := bfgd.L2Keystone{ + Version: 1, + L1BlockNumber: 11, + L2BlockNumber: 23, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + Hash: fillOutBytes("mockhashz", 32), + } + + err := db.L2KeystonesInsert(ctx, []bfgd.L2Keystone{l2Keystone, otherL2Keystone}) + if err != nil { + t.Fatal(err) + } + + l2KeystonesSaved, err := db.L2KeystonesMostRecentN(ctx, 5) + if err != nil { + t.Fatal(err) + } + + diff := deep.Equal(l2KeystonesSaved, []bfgd.L2Keystone{ + otherL2Keystone, + l2Keystone, + }) + + if len(diff) != 0 { + t.Fatalf("unexpected diff %s", diff) + } +} + +func TestL2KeystoneInsertMostRecentNFewerThanSaved(t *testing.T) { + ctx, cancel := defaultTestContext() + defer cancel() + + db, sdb, cleanup := createTestDB(ctx, t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + l2Keystone := bfgd.L2Keystone{ + Version: 1, + L1BlockNumber: 11, + L2BlockNumber: 22, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + Hash: fillOutBytes("mockhash", 32), + } + + otherL2Keystone := bfgd.L2Keystone{ + Version: 1, + L1BlockNumber: 11, + L2BlockNumber: 23, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + Hash: fillOutBytes("mockhashz", 32), + } + + err := db.L2KeystonesInsert(ctx, []bfgd.L2Keystone{l2Keystone, otherL2Keystone}) + if err != nil { + t.Fatal(err) + } + + l2KeystonesSaved, err := db.L2KeystonesMostRecentN(ctx, 1) + if err != nil { + t.Fatal(err) + } + + diff := deep.Equal(l2KeystonesSaved, []bfgd.L2Keystone{ + otherL2Keystone, + }) + + if len(diff) != 0 { + t.Fatalf("unexpected diff %s", diff) + } +} + +func TestL2KeystoneInsertMostRecentNLimit100(t *testing.T) { + ctx, cancel := defaultTestContext() + defer cancel() + + db, sdb, cleanup := createTestDB(ctx, t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + var l2BlockNumber uint32 = 100 + + toInsert := []bfgd.L2Keystone{} + + for i := 0; i < 101; func() { + i++ + l2BlockNumber++ + }() { + l2Keystone := bfgd.L2Keystone{ + Version: 1, + L1BlockNumber: 11, + L2BlockNumber: l2BlockNumber, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + Hash: fillOutBytes(fmt.Sprintf("mockhash%d", l2BlockNumber), 32), + } + + toInsert = append(toInsert, l2Keystone) + + } + + err := db.L2KeystonesInsert(ctx, toInsert) + if err != nil { + t.Fatal(err) + } + + l2KeystonesSaved, err := db.L2KeystonesMostRecentN(ctx, 1000) + if err != nil { + t.Fatal(err) + } + + if len(l2KeystonesSaved) != 100 { + t.Fatalf("was expected 100 l2keystones, received %d", len(l2KeystonesSaved)) + } +} + +func TestL2KeystoneInsertMultipleAtomicFailure(t *testing.T) { + ctx, cancel := defaultTestContext() + defer cancel() + + db, sdb, cleanup := createTestDB(ctx, t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + l2Keystone := bfgd.L2Keystone{ + Version: 1, + L1BlockNumber: 11, + L2BlockNumber: 22, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + Hash: fillOutBytes("mockhash", 31), // this will fail, the insert should thus fail + } + + otherL2Keystone := bfgd.L2Keystone{ + Version: 1, + L1BlockNumber: 11, + L2BlockNumber: 22, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + Hash: fillOutBytes("mockhashz", 32), + } + + err := db.L2KeystonesInsert(ctx, []bfgd.L2Keystone{l2Keystone, otherL2Keystone}) + if err == nil || errors.Is(err, database.ValidationError("")) == false { + t.Fatalf("insert should have failed but it did not: %s", err) + } + + count, err := db.L2KeystonesCount(ctx) + if err != nil { + t.Fatal(err) + } + + if count != 0 { + t.Fatal("nothing should have been inserted") + } +} + +func TestL2KeystoneInsertMultipleDuplicateError(t *testing.T) { + ctx, cancel := defaultTestContext() + defer cancel() + + db, sdb, cleanup := createTestDB(ctx, t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + l2Keystone := bfgd.L2Keystone{ + Version: 1, + L1BlockNumber: 11, + L2BlockNumber: 22, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + Hash: fillOutBytes("mockhash", 32), + } + + otherL2Keystone := bfgd.L2Keystone{ + Version: 1, + L1BlockNumber: 11, + L2BlockNumber: 22, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + Hash: fillOutBytes("mockhash", 32), + } + + err := db.L2KeystonesInsert(ctx, []bfgd.L2Keystone{l2Keystone, otherL2Keystone}) + if err == nil || errors.Is(err, database.DuplicateError("")) == false { + t.Fatalf("received unexpected error: %s", err) + } +} + +func TestPopBasisInsertNilMerklePath(t *testing.T) { + ctx, cancel := defaultTestContext() + defer cancel() + + db, sdb, cleanup := createTestDB(ctx, t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + popBasis := bfgd.PopBasis{ + BtcTxId: fillOutBytes("btctxid", 32), + BtcRawTx: fillOutBytes("btcrawtx", 80), + PopMinerPublicKey: []byte("popminerpublickey"), + L2KeystoneAbrevHash: fillOutBytes("l2keystoneabrevhash", 32), + } + + err := db.PopBasisInsertFull(ctx, &popBasis) + if err != nil { + t.Fatal(err) + } + + popBasesSaved, err := db.PopBasisByL2KeystoneAbrevHash( + ctx, + [32]byte(fillOutBytes("l2keystoneabrevhash", 32)), + false, + ) + if err != nil { + t.Fatal(err) + } + + if len(popBasesSaved) != 1 { + t.Fatalf("unexpected popBasesSaved length: %d", len(popBasesSaved)) + } + + if popBasesSaved[0].BtcMerklePath != nil { + t.Fatalf( + "expected nil merkle path, received: %v", + popBasesSaved[0].BtcMerklePath, + ) + } +} + +func TestPopBasisInsertNotNilMerklePath(t *testing.T) { + ctx, cancel := defaultTestContext() + defer cancel() + + db, sdb, cleanup := createTestDB(ctx, t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + popBasis := bfgd.PopBasis{ + BtcTxId: fillOutBytes("btctxid", 32), + BtcRawTx: fillOutBytes("btcrawtx", 80), + PopMinerPublicKey: []byte("popminerpublickey"), + L2KeystoneAbrevHash: fillOutBytes("l2keystoneabrevhash", 32), + BtcMerklePath: []string{"one", "two"}, + } + + err := db.PopBasisInsertFull(ctx, &popBasis) + if err != nil { + t.Fatal(err) + } + + popBasesSaved, err := db.PopBasisByL2KeystoneAbrevHash( + ctx, + [32]byte(fillOutBytes("l2keystoneabrevhash", 32)), + false, + ) + if err != nil { + t.Fatal(err) + } + + if len(popBasesSaved) != 1 { + t.Fatalf("unexpected popBasesSaved length: %d", len(popBasesSaved)) + } + + if slices.Equal(popBasesSaved[0].BtcMerklePath, []string{"one", "two"}) == false { + t.Fatalf( + "unexpected merkle path, received: %v", + popBasesSaved[0].BtcMerklePath, + ) + } +} + +func TestPopBasisInsertNilMerklePathFromPopM(t *testing.T) { + ctx, cancel := defaultTestContext() + defer cancel() + + db, sdb, cleanup := createTestDB(ctx, t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + popBasis := bfgd.PopBasis{ + BtcTxId: fillOutBytes("btctxid", 32), + BtcRawTx: fillOutBytes("btcrawtx", 80), + PopMinerPublicKey: []byte("popminerpublickey"), + L2KeystoneAbrevHash: fillOutBytes("l2keystoneabrevhash", 32), + } + + err := db.PopBasisInsertPopMFields(ctx, &popBasis) + if err != nil { + t.Fatal(err) + } + + popBasesSaved, err := db.PopBasisByL2KeystoneAbrevHash( + ctx, + [32]byte(fillOutBytes("l2keystoneabrevhash", 32)), + false, + ) + if err != nil { + t.Fatal(err) + } + + if len(popBasesSaved) != 1 { + t.Fatalf("unexpected popBasesSaved length: %d", len(popBasesSaved)) + } + + if popBasesSaved[0].BtcMerklePath != nil { + t.Fatalf( + "expected nil merkle path, received: %v", + popBasesSaved[0].BtcMerklePath, + ) + } +} + +func TestPopBasisUpdateNoneExist(t *testing.T) { + ctx, cancel := defaultTestContext() + defer cancel() + + db, sdb, cleanup := createTestDB(ctx, t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + btcBlock := bfgd.BtcBlock{ + Hash: fillOutBytes("myhash", 32), + Header: fillOutBytes("myheader", 80), + Height: 1, + } + + err := db.BtcBlockInsert(ctx, &btcBlock) + if err != nil { + t.Fatalf("Failed to insert bitcoin block: %v", err) + } + + var txIndex uint64 = 2 + + popBasis := bfgd.PopBasis{ + BtcTxId: fillOutBytes("btctxid2", 32), + BtcRawTx: []byte("btcrawtx2"), + PopTxId: fillOutBytes("poptxid2", 32), + L2KeystoneAbrevHash: fillOutBytes("l2keystoneabrevhash", 32), + PopMinerPublicKey: fillOutBytes("popminerpublickey", 32), + BtcHeaderHash: btcBlock.Hash, + BtcTxIndex: &txIndex, + } + + rowsAffected, err := db.PopBasisUpdateBTCFields(ctx, &popBasis) + if err != nil { + t.Fatal(err) + } + + if rowsAffected != 0 { + t.Fatalf("unexpected number of rows affected %d", rowsAffected) + } +} + +func TestPopBasisUpdateOneExistsWithNonNullBTCFields(t *testing.T) { + ctx, cancel := defaultTestContext() + defer cancel() + + db, sdb, cleanup := createTestDB(ctx, t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + btcBlock := bfgd.BtcBlock{ + Hash: fillOutBytes("myhash", 32), + Header: fillOutBytes("myheader", 80), + Height: 1, + } + + err := db.BtcBlockInsert(ctx, &btcBlock) + if err != nil { + t.Fatalf("Failed to insert bitcoin block: %v", err) + } + + var txIndex uint64 = 2 + + popBasis := bfgd.PopBasis{ + BtcTxId: fillOutBytes("btctxid2", 32), + BtcRawTx: []byte("btcrawtx2"), + PopTxId: fillOutBytes("poptxid2", 32), + L2KeystoneAbrevHash: fillOutBytes("l2keystoneabrevhash", 32), + PopMinerPublicKey: fillOutBytes("popminerpublickey", 32), + BtcHeaderHash: btcBlock.Hash, + BtcTxIndex: &txIndex, + } + + err = db.PopBasisInsertFull(ctx, &popBasis) + if err != nil { + t.Fatal(err) + } + + var txIndex2 uint64 = 3 + + popBasis2 := bfgd.PopBasis{ + BtcTxId: fillOutBytes("btctxid2", 32), + BtcRawTx: []byte("btcrawtx2"), + PopTxId: fillOutBytes("poptxid2", 32), + L2KeystoneAbrevHash: fillOutBytes("l2keystoneabrevhash", 32), + PopMinerPublicKey: fillOutBytes("popminerpublickey", 32), + BtcHeaderHash: btcBlock.Hash, + BtcTxIndex: &txIndex2, + } + + rowsAffected, err := db.PopBasisUpdateBTCFields(ctx, &popBasis2) + if err != nil { + t.Fatal(err) + } + + if rowsAffected != 0 { + t.Fatalf("unexpected number of rows affected %d", rowsAffected) + } + + popBases, err := db.PopBasisByL2KeystoneAbrevHash( + ctx, + [32]byte(fillOutBytes("l2keystoneabrevhash", 32)), + false, + ) + if err != nil { + t.Fatal(err) + } + + diff := deep.Equal(popBases, []bfgd.PopBasis{ + popBasis, + }) + + if len(diff) != 0 { + t.Fatalf("unexpected diff %s", diff) + } + + err = db.PopBasisInsertFull(ctx, &popBasis2) + if err != nil { + t.Fatal(err) + } + + popBases, err = db.PopBasisByL2KeystoneAbrevHash( + ctx, + [32]byte(fillOutBytes("l2keystoneabrevhash", 32)), + false, + ) + if err != nil { + t.Fatal(err) + } + + sortFn := func(a, b bfgd.PopBasis) int { + if *a.BtcTxIndex < *b.BtcTxIndex { + return -1 + } + + return 1 + } + + slices.SortFunc(popBases, sortFn) + + diff = deep.Equal(popBases, []bfgd.PopBasis{ + popBasis, + popBasis2, + }) + + if len(diff) != 0 { + t.Fatalf("unexpected diff %s", diff) + } +} + +func TestPopBasisUpdateOneExistsWithNullBTCFields(t *testing.T) { + ctx, cancel := defaultTestContext() + defer cancel() + + db, sdb, cleanup := createTestDB(ctx, t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + btcBlock := bfgd.BtcBlock{ + Hash: fillOutBytes("myhash", 32), + Header: fillOutBytes("myheader", 80), + Height: 1, + } + + err := db.BtcBlockInsert(ctx, &btcBlock) + if err != nil { + t.Fatalf("Failed to insert bitcoin block: %v", err) + } + + popBasis := bfgd.PopBasis{ + BtcTxId: fillOutBytes("btctxid2", 32), + BtcRawTx: []byte("btcrawtx2"), + L2KeystoneAbrevHash: fillOutBytes("l2keystoneabrevhash", 32), + PopMinerPublicKey: fillOutBytes("popminerpublickey", 32), + PopTxId: nil, + BtcHeaderHash: nil, + BtcTxIndex: nil, + BtcMerklePath: nil, + } + + err = db.PopBasisInsertPopMFields(ctx, &popBasis) + if err != nil { + t.Fatal(err) + } + + var txIndex uint64 = 3 + + popBasis2 := bfgd.PopBasis{ + BtcTxId: fillOutBytes("btctxid2", 32), + BtcRawTx: []byte("btcrawtx2"), + PopTxId: fillOutBytes("poptxid2", 32), + L2KeystoneAbrevHash: fillOutBytes("l2keystoneabrevhash", 32), + PopMinerPublicKey: fillOutBytes("popminerpublickey", 32), + BtcHeaderHash: btcBlock.Hash, + BtcTxIndex: &txIndex, + } + + rowsAffected, err := db.PopBasisUpdateBTCFields(ctx, &popBasis2) + if err != nil { + t.Fatal(err) + } + + if rowsAffected != 1 { + t.Fatalf("unexpected number of rows affected %d", rowsAffected) + } + + popBases, err := db.PopBasisByL2KeystoneAbrevHash( + ctx, + [32]byte(fillOutBytes("l2keystoneabrevhash", 32)), + false, + ) + if err != nil { + t.Fatal(err) + } + + diff := deep.Equal(popBases, []bfgd.PopBasis{ + popBasis2, + }) + + if len(diff) != 0 { + t.Fatalf("unexpected diff %s", diff) + } +} + +func TestBtcBlockGetCanonicalChain(t *testing.T) { + type testTableItem struct { + name string + onChainCount int + offChainCount int + } + + testTable := []testTableItem{ + { + name: "2 on, 1 off", + onChainCount: 2, + offChainCount: 1, + }, + { + name: "1 on, 2 off", + onChainCount: 1, + offChainCount: 2, + }, + { + name: "100 on, 99 off", + onChainCount: 100, + offChainCount: 99, + }, + } + + for _, tti := range testTable { + t.Run(tti.name, func(t *testing.T) { + ctx, cancel := defaultTestContext() + defer cancel() + + db, sdb, cleanup := createTestDB(ctx, t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + height := 1 + l2BlockNumber := uint32(9999) + + onChainBlocks := []bfgd.BtcBlock{} + + // create off-chain blocks + offChainBlocks := createBtcBlocksAtStartingHeight(ctx, t, db, tti.offChainCount, false, height, []byte{}, l2BlockNumber) + if len(offChainBlocks) != tti.offChainCount { + t.Fatalf("created an incorrect number of on-chain blocks %d", + len(offChainBlocks), + ) + } + + height += 10000 + l2BlockNumber += 1000 + // create on-chain blocks + onChainBlocks = createBtcBlocksAtStartingHeight(ctx, t, db, tti.onChainCount, true, height, []byte{}, l2BlockNumber) + + limit := tti.onChainCount + + bfs, err := db.L2BTCFinalityMostRecent(ctx, uint32(limit)) + if err != nil { + t.Fatal(err) + } + + if len(bfs) > limit { + t.Fatalf("bfs too long %d", len(bfs)) + } + + slices.Reverse(onChainBlocks) + + for i, block := range onChainBlocks { + if i == limit { + break + } + found := false + for k, v := range bfs { + if slices.Equal(block.Hash, v.BTCPubHeaderHash) == true { + t.Logf("found hash in result set: %s", block.Hash) + found = true + if k < len(bfs)-1 { + t.Logf("next has is %s", bfs[k+1].BTCPubHeaderHash) + } + } + } + if found == false { + t.Fatalf("could not find hash in result set: %s", block.Hash) + } + } + + for _, block := range offChainBlocks { + found := false + for _, v := range bfs { + if slices.Equal(block.Hash, v.BTCPubHeaderHash) == true { + t.Logf("found hash in result set: %s", block.Hash) + found = true + } + } + if found == true { + t.Fatalf("hash should not have been included in result set: %s", block.Hash) + } + } + }) + } +} + +func TestBtcBlockGetCanonicalChainWithForks(t *testing.T) { + type testTableItem struct { + name string + chainPattern []int + unconfirmedIndices []bool + } + + testTable := []testTableItem{ + { + name: "fork at tip", + chainPattern: []int{1, 1, 2}, + unconfirmedIndices: []bool{false, false, false, false}, + }, + { + name: "fork in middle", + chainPattern: []int{1, 2, 1}, + unconfirmedIndices: []bool{false, false, false, false}, + }, + { + name: "fork in beginning", + chainPattern: []int{2, 1, 1}, + unconfirmedIndices: []bool{false, false, false, false}, + }, + { + name: "fork in beginning with break", + chainPattern: []int{2, 1, 1, 1}, + unconfirmedIndices: []bool{false, false, true, false}, + }, + { + name: "fork in beginning with multiple breaks", + chainPattern: []int{2, 1, 1, 1, 1}, + unconfirmedIndices: []bool{false, true, false, true, false}, + }, + } + + for _, tti := range testTable { + t.Run(tti.name, func(t *testing.T) { + ctx, cancel := defaultTestContext() + defer cancel() + + db, sdb, cleanup := createTestDB(ctx, t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + height := 1 + + onChainBlocks := []bfgd.BtcBlock{} + + l2BlockNumber := uint32(1000) + lastHash := []byte{} + for i, blockCountAtHeight := range tti.chainPattern { + tmp := height + if tti.unconfirmedIndices[i] == true { + tmp = -1 + } + _onChainBlocks := createBtcBlocksAtStaticHeight(ctx, t, db, blockCountAtHeight, true, tmp, lastHash, l2BlockNumber) + l2BlockNumber++ + height++ + lastHash = _onChainBlocks[0].Hash + + if (blockCountAtHeight > 1 && i == len(tti.chainPattern)-1) == false { + onChainBlocks = append(onChainBlocks, _onChainBlocks[0]) + } + } + + bfs, err := db.L2BTCFinalityMostRecent(ctx, 100) + if err != nil { + t.Fatal(err) + } + + if len(onChainBlocks) != len(bfs) { + t.Fatalf("length of onChainBlocks and pbs differs %d != %d", len(onChainBlocks), len(bfs)) + } + + slices.Reverse(onChainBlocks) + + for i := range onChainBlocks { + if slices.Equal(onChainBlocks[i].Hash, bfs[i].BTCPubHeaderHash[:]) == false { + t.Fatalf("hash mismatch: %s != %s", onChainBlocks[i].Hash, bfs[i].BTCPubHeaderHash) + } + } + }) + } +} + +func TestPublications(t *testing.T) { + type testTableItem struct { + name string + heightPattern []int + expectedHeights []int + } + + testTable := []testTableItem{ + { + name: "height in order", + heightPattern: []int{1, 2, 3, 4}, + expectedHeights: []int{4, 3, 2, 1}, + }, + { + name: "height in order unconfirmed", + heightPattern: []int{1, 2, -1, 4}, // use -1 to indicate unconfirmed + expectedHeights: []int{4, -1, 2, 1}, + }, + } + + for _, tti := range testTable { + t.Run(tti.name, func(t *testing.T) { + ctx, cancel := defaultTestContext() + defer cancel() + + db, sdb, cleanup := createTestDB(ctx, t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + l2BlockNumber := uint32(1000) + + lastHash := []byte{} + for _, height := range tti.heightPattern { + _onChainBlocks := createBtcBlocksAtStaticHeight(ctx, t, db, 1, true, height, lastHash, l2BlockNumber) + lastHash = _onChainBlocks[0].Hash + l2BlockNumber++ + } + + bfs, err := db.L2BTCFinalityMostRecent(ctx, 100) + if err != nil { + t.Fatal(err) + } + + for _, v := range bfs { + t.Logf("height is %d", v.BTCPubHeight) + } + + for i := range tti.expectedHeights { + if int64(tti.expectedHeights[i]) != bfs[i].BTCPubHeight { + t.Fatalf("height mismatch at index %d (block %d): %d != %d", i, bfs[i].L2Keystone.L2BlockNumber, tti.expectedHeights[i], bfs[i].BTCPubHeight) + } + } + }) + } +} + +func TestL2BtcFinalitiesByL2Keystone(t *testing.T) { + ctx, cancel := defaultTestContext() + defer cancel() + + db, sdb, cleanup := createTestDB(ctx, t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + createBtcBlocksAtStartingHeight(ctx, t, db, 2, true, 8987, []byte{}, 646464) + + l2Keystones, err := db.L2KeystonesMostRecentN(ctx, 2) + if err != nil { + t.Fatal(err) + } + + firstKeystone := l2Keystones[0] + + finalities, err := db.L2BTCFinalityByL2KeystoneAbrevHash( + ctx, + []database.ByteArray{firstKeystone.Hash}, + ) + if err != nil { + t.Fatal(err) + } + + if len(finalities) != 1 { + t.Fatalf("received unexpected number of finalities: %d", len(finalities)) + } + + diff := deep.Equal(firstKeystone, finalities[0].L2Keystone) + if len(diff) > 0 { + t.Fatalf("unexpected diff %s", diff) + } + + if finalities[0].BTCPubHeight != 8988 { + t.Fatalf("incorrect height %d", finalities[0].BTCPubHeight) + } +} + +func TestL2BtcFinalitiesByL2KeystoneNotPublishedHeight(t *testing.T) { + ctx, cancel := defaultTestContext() + defer cancel() + + db, sdb, cleanup := createTestDB(ctx, t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + createBtcBlocksAtStaticHeight(ctx, t, db, 1, true, -1, []byte{}, 646464) + + l2Keystones, err := db.L2KeystonesMostRecentN(ctx, 2) + if err != nil { + t.Fatal(err) + } + + firstKeystone := l2Keystones[0] + + finalities, err := db.L2BTCFinalityByL2KeystoneAbrevHash( + ctx, + []database.ByteArray{firstKeystone.Hash}, + ) + if err != nil { + t.Fatal(err) + } + + if len(finalities) != 1 { + t.Fatalf("received unexpected number of finalities: %d", len(finalities)) + } + + diff := deep.Equal(firstKeystone, finalities[0].L2Keystone) + if len(diff) > 0 { + t.Fatalf("unexpected diff %s", diff) + } + + if finalities[0].BTCPubHeight != -1 { + t.Fatalf("incorrect height %d", finalities[0].BTCPubHeight) + } +} + +func createBtcBlock(ctx context.Context, t *testing.T, db bfgd.Database, count int, chain bool, height int, lastHash []byte, l2BlockNumber uint32) bfgd.BtcBlock { + header := make([]byte, 80) + hash := make([]byte, 32) + l2KeystoneAbrevHash := make([]byte, 32) + parentEpHash := make([]byte, 32) + prevKeystoneEpHash := make([]byte, 32) + stateRoot := make([]byte, 32) + epHash := make([]byte, 32) + btcTxId := make([]byte, 32) + btcRawTx := make([]byte, 32) + popMinerPublicKey := make([]byte, 32) + + _, err := rand.Read(header) + if err != nil { + t.Fatal(err) + } + + _, err = rand.Read(hash) + if err != nil { + t.Fatal(err) + } + + _, err = rand.Read(l2KeystoneAbrevHash) + if err != nil { + t.Fatal(err) + } + + _, err = rand.Read(btcTxId) + if err != nil { + t.Fatal(err) + } + + if chain { + // create the chain using lastHash if it is set (len > 0), + // if it is not set, we are on the tip + if len(lastHash) != 0 { + for k := 4; (k - 4) < 32; k++ { + header[k] = lastHash[k-4] + } + } + + lastHash = hash + } + + t.Logf( + "inserting with height %d and L2BlockNumber %d hash %s", + height, l2BlockNumber, hex.EncodeToString(hash)) + + btcBlock := bfgd.BtcBlock{ + Header: header, + Hash: hash, + Height: uint64(height), + } + + l2Keystone := bfgd.L2Keystone{ + Hash: l2KeystoneAbrevHash, + ParentEPHash: parentEpHash, + PrevKeystoneEPHash: prevKeystoneEpHash, + StateRoot: stateRoot, + EPHash: epHash, + L2BlockNumber: l2BlockNumber, + } + + popBasis := bfgd.PopBasis{ + BtcTxId: btcTxId, + BtcRawTx: btcRawTx, + BtcHeaderHash: hash, + L2KeystoneAbrevHash: l2KeystoneAbrevHash, + PopMinerPublicKey: popMinerPublicKey, + } + + if height == -1 { + err = db.L2KeystonesInsert(ctx, []bfgd.L2Keystone{l2Keystone}) + if err != nil { + t.Fatal(err) + } + + err = db.PopBasisInsertPopMFields(ctx, &popBasis) + if err != nil { + t.Fatal(err) + } + + return bfgd.BtcBlock{} + } + + err = db.BtcBlockInsert(ctx, &btcBlock) + if err != nil { + t.Fatal(err) + } + + err = db.L2KeystonesInsert(ctx, []bfgd.L2Keystone{l2Keystone}) + if err != nil { + t.Fatal(err) + } + + err = db.PopBasisInsertFull(ctx, &popBasis) + if err != nil { + t.Fatal(err) + } + + return btcBlock +} + +func createBtcBlocksAtStaticHeight(ctx context.Context, t *testing.T, db bfgd.Database, count int, chain bool, height int, lastHash []byte, l2BlockNumber uint32) []bfgd.BtcBlock { + blocks := []bfgd.BtcBlock{} + + for i := 0; i < count; i++ { + btcBlock := createBtcBlock( + ctx, + t, + db, + count, + chain, + height, + lastHash, + l2BlockNumber, + ) + blocks = append(blocks, btcBlock) + lastHash = btcBlock.Hash + } + + return blocks +} + +func createBtcBlocksAtStartingHeight(ctx context.Context, t *testing.T, db bfgd.Database, count int, chain bool, height int, lastHash []byte, l2BlockNumber uint32) []bfgd.BtcBlock { + blocks := []bfgd.BtcBlock{} + + for i := 0; i < count; i++ { + btcBlock := createBtcBlock( + ctx, + t, + db, + count, + chain, + height, + lastHash, + l2BlockNumber, + ) + blocks = append(blocks, btcBlock) + height++ + l2BlockNumber++ + lastHash = btcBlock.Hash + } + + return blocks +} diff --git a/database/bfgd/postgres/postgres.go b/database/bfgd/postgres/postgres.go new file mode 100644 index 00000000..fe160b9b --- /dev/null +++ b/database/bfgd/postgres/postgres.go @@ -0,0 +1,1059 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package postgres + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + + "github.com/juju/loggo" + "github.com/lib/pq" + + "github.com/hemilabs/heminetwork/database" + "github.com/hemilabs/heminetwork/database/bfgd" + "github.com/hemilabs/heminetwork/database/postgres" +) + +const ( + bfgdVersion = 6 + + logLevel = "INFO" + verbose = false +) + +const effectiveHeightSql = ` + COALESCE((SELECT MIN(height) + + FROM + ( + SELECT height FROM btc_blocks_can + INNER JOIN pop_basis ON pop_basis.btc_block_hash + = btc_blocks_can.hash + INNER JOIN l2_keystones ll ON ll.l2_keystone_abrev_hash + = pop_basis.l2_keystone_abrev_hash + + WHERE ll.l2_block_number >= l2_keystones.l2_block_number + )), 0) +` + +var log = loggo.GetLogger("bfgpostgres") + +func init() { + loggo.ConfigureLoggers(logLevel) +} + +type pgdb struct { + *postgres.Database + db *sql.DB +} + +var _ bfgd.Database = (*pgdb)(nil) + +// Connect connects to a postgres database. This is only used in tests. +func Connect(ctx context.Context, uri string) (*sql.DB, error) { + return postgres.Connect(ctx, uri) +} + +func New(ctx context.Context, uri string) (*pgdb, error) { + log.Tracef("New") + defer log.Tracef("New exit") + + pg, err := postgres.New(ctx, uri, bfgdVersion) + if err != nil { + return nil, err + } + log.Debugf("bfgdb database version: %v", bfgdVersion) + p := &pgdb{ + Database: pg, + db: pg.DB(), + } + + // first, refresh the materialized view so it can be used in case it was + // never refreshed before this point + err = p.refreshBTCBlocksCanonical(ctx) + if err != nil { + return nil, err + } + + return p, nil +} + +func (pg *pgdb) Version(ctx context.Context) (int, error) { + log.Tracef("Version") + defer log.Tracef("Version exit") + + const selectVersion = `SELECT * FROM version LIMIT 1;` + var dbVersion int + if err := pg.db.QueryRowContext(ctx, selectVersion).Scan(&dbVersion); err != nil { + return -1, err + } + return dbVersion, nil +} + +func (pg *pgdb) L2KeystonesCount(ctx context.Context) (int, error) { + log.Tracef("L2KeystonesCount") + defer log.Tracef("L2KeystonesCount exit") + + const selectCount = `SELECT COUNT(*) FROM l2_keystones;` + var count int + if err := pg.db.QueryRowContext(ctx, selectCount).Scan(&count); err != nil { + return 0, err + } + + return count, nil +} + +func (p *pgdb) L2KeystonesInsert(ctx context.Context, l2ks []bfgd.L2Keystone) error { + log.Tracef("L2KeystonesInsert") + defer log.Tracef("L2KeystonesInsert exit") + + if len(l2ks) == 0 { + log.Errorf("empty l2 keystones, nothing to do") + return nil + } + + tx, err := p.db.BeginTx(ctx, nil) + if err != nil { + return err + } + + defer func() { + err := tx.Rollback() + if err != nil && err != sql.ErrTxDone { + log.Errorf("L2KeystonesInsert could not rollback db tx: %v", + err) + return + } + }() + + const qInsertL2Keystone = ` + INSERT INTO l2_keystones ( + l2_keystone_abrev_hash, + l1_block_number, + l2_block_number, + parent_ep_hash, + prev_keystone_ep_hash, + state_root, + ep_hash, + version + ) + + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ` + + for _, v := range l2ks { + result, err := tx.ExecContext(ctx, qInsertL2Keystone, v.Hash, + v.L1BlockNumber, v.L2BlockNumber, v.ParentEPHash, + v.PrevKeystoneEPHash, v.StateRoot, v.EPHash, v.Version) + if err != nil { + if err, ok := err.(*pq.Error); ok && err.Code.Class().Name() == "integrity_constraint_violation" { + switch err.Constraint { + case "l2_keystone_abrev_hash_length", + "state_root_length", + "parent_ep_hash_length", + "prev_keystone_ep_hash_length", + "ep_hash_length": + return database.ValidationError(err.Error()) + } + + log.Errorf("integrity violation occurred: %s", err.Constraint) + return database.DuplicateError(fmt.Sprintf("constraint error: %s", err)) + } + return fmt.Errorf("failed to insert l2 keystone: %v", err) + } + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to insert l2 keystone rows affected: %v", err) + } + if rows < 1 { + return fmt.Errorf("failed to insert l2 keystone rows: %v", rows) + } + } + + err = tx.Commit() + if err != nil { + return err + } + + return nil +} + +func (p *pgdb) L2KeystoneByAbrevHash(ctx context.Context, aHash [32]byte) (*bfgd.L2Keystone, error) { + log.Tracef("L2KeystoneByAbrevHash") + defer log.Tracef("L2KeystoneByAbrevHash exit") + + const q = ` + SELECT + l2_keystone_abrev_hash, + l1_block_number, + l2_block_number, + parent_ep_hash, + prev_keystone_ep_hash, + state_root, + ep_hash, + version, + created_at, + updated_at + + FROM l2_keystones + WHERE l2_keystone_abrev_hash = $1 + ` + + l2ks := &bfgd.L2Keystone{} + row := p.db.QueryRowContext(ctx, q, aHash[:]) + if err := row.Scan(&l2ks.Hash, &l2ks.L1BlockNumber, &l2ks.L2BlockNumber, + &l2ks.ParentEPHash, &l2ks.PrevKeystoneEPHash, &l2ks.StateRoot, + &l2ks.EPHash, &l2ks.Version, &l2ks.CreatedAt, &l2ks.UpdatedAt, + ); err != nil { + if err == sql.ErrNoRows { + return nil, database.NotFoundError("l2 keystone not found") + } + return nil, err + } + return l2ks, nil +} + +func (p *pgdb) L2KeystonesMostRecentN(ctx context.Context, n uint32) ([]bfgd.L2Keystone, error) { + log.Tracef("L2KeystonesMostRecentN") + defer log.Tracef("L2KeystonesMostRecentN exit") + + if n > 100 { + n = 100 + } + + const q = ` + SELECT + l2_keystone_abrev_hash, + l1_block_number, + l2_block_number, + parent_ep_hash, + prev_keystone_ep_hash, + state_root, + ep_hash, + version, + created_at, + updated_at + + FROM l2_keystones + ORDER BY l2_block_number DESC LIMIT $1 + ` + + var ks []bfgd.L2Keystone + rows, err := p.db.QueryContext(ctx, q, n) + if err != nil { + return nil, err + } + + defer rows.Close() + + for rows.Next() { + var k bfgd.L2Keystone + if err := rows.Scan(&k.Hash, &k.L1BlockNumber, &k.L2BlockNumber, + &k.ParentEPHash, &k.PrevKeystoneEPHash, &k.StateRoot, + &k.EPHash, &k.Version, &k.CreatedAt, &k.UpdatedAt, + ); err != nil { + if err == sql.ErrNoRows { + return nil, database.NotFoundError("pop data not found") + } + return nil, err + } + ks = append(ks, k) + } + + if rows.Err() != nil { + return nil, rows.Err() + } + + return ks, nil +} + +func (p *pgdb) BtcBlockInsert(ctx context.Context, bb *bfgd.BtcBlock) error { + log.Tracef("BtcBlockInsert") + defer log.Tracef("BtcBlockInsert exit") + const qBtcBlockInsert = ` + INSERT INTO btc_blocks (hash, header, height) + VALUES ($1, $2, $3) + ` + result, err := p.db.ExecContext(ctx, qBtcBlockInsert, bb.Hash, bb.Header, + bb.Height) + if err != nil { + if err, ok := err.(*pq.Error); ok && err.Code.Class().Name() == "integrity_constraint_violation" { + return database.DuplicateError(fmt.Sprintf("duplicate btc block entry: %s", err)) + } + return fmt.Errorf("failed to insert btc block: %v", err) + } + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to insert btc block rows affected: %v", err) + } + if rows < 1 { + return fmt.Errorf("failed to insert btc block rows: %v", rows) + } + + return nil +} + +func (p *pgdb) BtcBlockByHash(ctx context.Context, hash [32]byte) (*bfgd.BtcBlock, error) { + log.Tracef("BtcBlockByHash") + defer log.Tracef("BtcBlockByHash exit") + + const q = ` + SELECT hash, header, height, created_at, updated_at + FROM btc_blocks + WHERE hash = $1 + ` + + bb := &bfgd.BtcBlock{} + row := p.db.QueryRowContext(ctx, q, hash[:]) + if err := row.Scan(&bb.Hash, &bb.Header, &bb.Height, &bb.CreatedAt, + &bb.UpdatedAt); err != nil { + if err == sql.ErrNoRows { + return nil, database.NotFoundError("btc block not found") + } + return nil, err + } + return bb, nil +} + +func (p *pgdb) BtcBlockHeightByHash(ctx context.Context, hash [32]byte) (uint64, error) { + log.Tracef("BtcBlockHeightByHash") + defer log.Tracef("BtcBlockHeightByHash exit") + + const q = ` + SELECT height + FROM btc_blocks + WHERE hash = $1 + ` + + var height uint64 + row := p.db.QueryRowContext(ctx, q, hash[:]) + if err := row.Scan(&height); err != nil { + if err == sql.ErrNoRows { + return 0, database.NotFoundError("btc block height not found") + } + return 0, err + } + return height, nil +} + +func (p *pgdb) PopBasisInsertPopMFields(ctx context.Context, pb *bfgd.PopBasis) error { + log.Tracef("PopBasisInsertPopMFields") + defer log.Tracef("PopBasisInsertPopMFields exit") + const qPopBlockInsert = ` + INSERT INTO pop_basis ( + btc_txid, + btc_raw_tx, + l2_keystone_abrev_hash, + pop_miner_public_key + ) + VALUES ($1, $2, $3, $4) + ` + result, err := p.db.ExecContext(ctx, qPopBlockInsert, pb.BtcTxId, pb.BtcRawTx, + pb.L2KeystoneAbrevHash, pb.PopMinerPublicKey) + if err != nil { + if err, ok := err.(*pq.Error); ok && err.Code.Class().Name() == "integrity_constraint_violation" { + switch err.Constraint { + case "btc_txid_length": + return database.ValidationError("BtcTxId must be length 32") + default: + return database.DuplicateError(fmt.Sprintf("duplicate pop block entry: %s", err.Error())) + } + } + return fmt.Errorf("failed to insert pop block: %v", err) + } + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to insert pop block rows affected: %v", err) + } + if rows < 1 { + return fmt.Errorf("failed to insert pop block rows: %v", rows) + } + + return nil +} + +func (p *pgdb) PopBasisUpdateBTCFields(ctx context.Context, pb *bfgd.PopBasis) (int64, error) { + log.Tracef("PopBasisUpdateBTCFields") + defer log.Tracef("PopBasisUpdateBTCFields exit") + b, err := json.Marshal(pb.BtcMerklePath) + if err != nil { + return 0, err + } + + q := ` + UPDATE pop_basis SET + btc_block_hash = $1, + btc_merkle_path = $2, + pop_txid = $3, + btc_tx_index = $4, + updated_at = NOW() + + WHERE + btc_txid = $5 + + -- ensure these fields are null so that we + -- don't overwrite another valid btc block (ex. a fork) + AND btc_block_hash IS NULL + AND btc_merkle_path IS NULL + AND pop_txid IS NULL + AND btc_tx_index IS NULL + ` + + result, err := p.db.ExecContext(ctx, q, pb.BtcHeaderHash, string(b), pb.PopTxId, + pb.BtcTxIndex, pb.BtcTxId, + ) + if err != nil { + if err, ok := err.(*pq.Error); ok && err.Code.Class().Name() == "integrity_constraint_violation" { + switch err.Constraint { + case "pop_txid_length": + return 0, database.ValidationError("PopTxId must be length 32") + default: + return 0, database.DuplicateError(fmt.Sprintf("duplicate pop block entry: %s", err.Error())) + } + } + return 0, fmt.Errorf("failed to insert pop block: %v", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return 0, fmt.Errorf("failed to insert pop block rows affected: %v", err) + } + + return rows, nil +} + +func (p *pgdb) PopBasisInsertFull(ctx context.Context, pb *bfgd.PopBasis) error { + log.Tracef("PopBasisInsertFull") + defer log.Tracef("PopBasisInsertFull exit") + + b, err := json.Marshal(pb.BtcMerklePath) + if err != nil { + return err + } + const qPopBlockInsert = ` + INSERT INTO pop_basis ( + btc_txid, + btc_raw_tx, + btc_block_hash, + btc_tx_index, + btc_merkle_path, + pop_txid, + l2_keystone_abrev_hash, + pop_miner_public_key + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (btc_txid, btc_raw_tx, btc_block_hash, btc_tx_index) + DO NOTHING + ` + result, err := p.db.ExecContext(ctx, qPopBlockInsert, pb.BtcTxId, pb.BtcRawTx, + pb.BtcHeaderHash, pb.BtcTxIndex, string(b), pb.PopTxId, + pb.L2KeystoneAbrevHash, pb.PopMinerPublicKey) + if err != nil { + if err, ok := err.(*pq.Error); ok && err.Code.Class().Name() == "integrity_constraint_violation" { + switch err.Constraint { + case "btc_txid_length": + return database.ValidationError("BtcTxId must be length 32") + case "pop_txid_length": + return database.ValidationError("PopTxId must be length 32") + default: + return database.DuplicateError(fmt.Sprintf("duplicate pop block entry: %s", err.Error())) + } + } + return fmt.Errorf("failed to insert pop block: %v", err) + } + rows, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to insert pop block rows affected: %v", err) + } + if rows < 1 { + return fmt.Errorf("failed to insert pop block rows: %v", rows) + } + + return nil +} + +func (p *pgdb) PopBasisByL2KeystoneAbrevHash(ctx context.Context, aHash [32]byte, excludeUnconfirmed bool) ([]bfgd.PopBasis, error) { + q := ` + SELECT + id, + btc_txid, + btc_raw_tx, + btc_block_hash, + btc_tx_index, + btc_merkle_path, + pop_txid, + l2_keystone_abrev_hash, + pop_miner_public_key, + created_at, + updated_at + + FROM pop_basis + WHERE l2_keystone_abrev_hash = $1 + ` + + if excludeUnconfirmed == true { + q += " AND btc_block_hash IS NOT NULL" + } + + pbs := []bfgd.PopBasis{} + log.Infof("querying for hash: %v", database.ByteArray(aHash[:])) + rows, err := p.db.QueryContext(ctx, q, aHash[:]) + if err != nil { + return nil, err + } + + defer rows.Close() + + for rows.Next() { + var popBasis bfgd.PopBasis + var btcMerklePathTmp *string + + err := rows.Scan( + &popBasis.ID, + &popBasis.BtcTxId, + &popBasis.BtcRawTx, + &popBasis.BtcHeaderHash, + &popBasis.BtcTxIndex, + &btcMerklePathTmp, + &popBasis.PopTxId, + &popBasis.L2KeystoneAbrevHash, + &popBasis.PopMinerPublicKey, + &popBasis.CreatedAt, + &popBasis.UpdatedAt, + ) + if err != nil { + return nil, err + } + + if btcMerklePathTmp != nil { + err = json.Unmarshal([]byte(*btcMerklePathTmp), + &popBasis.BtcMerklePath) + if err != nil { + return nil, err + } + } + pbs = append(pbs, popBasis) + } + + if rows.Err() != nil { + return nil, rows.Err() + } + + return pbs, nil +} + +// nextL2BTCFinalitiesPublished , given a block number (lessThanL2BlockNumber) +// will find the the next smallest published finality on the canoncial chain +func (p *pgdb) nextL2BTCFinalitiesPublished(ctx context.Context, lessThanL2BlockNumber uint32, limit int) ([]bfgd.L2BTCFinality, error) { + sql := fmt.Sprintf(` + SELECT + btc_blocks_can.hash, + btc_blocks_can.height, + l2_keystones.l2_keystone_abrev_hash, + l2_keystones.l1_block_number, + l2_keystones.l2_block_number, + l2_keystones.parent_ep_hash, + l2_keystones.prev_keystone_ep_hash, + l2_keystones.state_root, + l2_keystones.ep_hash, + l2_keystones.version, + %s, + COALESCE((SELECT MAX(height) FROM btc_blocks_can), 0) + FROM btc_blocks_can + + INNER JOIN pop_basis ON pop_basis.btc_block_hash = btc_blocks_can.hash + INNER JOIN l2_keystones ON l2_keystones.l2_keystone_abrev_hash + = pop_basis.l2_keystone_abrev_hash + + WHERE l2_keystones.l2_block_number <= $1 + ORDER BY height DESC, l2_keystones.l2_block_number DESC LIMIT $2 + `, effectiveHeightSql) + + rows, err := p.db.QueryContext(ctx, sql, lessThanL2BlockNumber, limit) + if err != nil { + return nil, err + } + + defer rows.Close() + + finalities := []bfgd.L2BTCFinality{} + + for rows.Next() { + var l2BtcFinality bfgd.L2BTCFinality + err = rows.Scan( + &l2BtcFinality.BTCPubHeaderHash, + &l2BtcFinality.BTCPubHeight, + &l2BtcFinality.L2Keystone.Hash, + &l2BtcFinality.L2Keystone.L1BlockNumber, + &l2BtcFinality.L2Keystone.L2BlockNumber, + &l2BtcFinality.L2Keystone.ParentEPHash, + &l2BtcFinality.L2Keystone.PrevKeystoneEPHash, + &l2BtcFinality.L2Keystone.StateRoot, + &l2BtcFinality.L2Keystone.EPHash, + &l2BtcFinality.L2Keystone.Version, + &l2BtcFinality.EffectiveHeight, + &l2BtcFinality.BTCTipHeight, + ) + if err != nil { + return nil, err + } + + finalities = append(finalities, l2BtcFinality) + } + + if rows.Err() != nil { + return nil, rows.Err() + } + + return finalities, nil +} + +// nextL2BTCFinalitiesAssumedUnpublished , given a block number (lessThanL2BlockNumber) +// will find the the next smallest published finality that is not within explicitExcludeL2BlockNumbers +// and assume it is unpublished (returning nothing for BTC fields) +func (p *pgdb) nextL2BTCFinalitiesAssumedUnpublished(ctx context.Context, lessThanL2BlockNumber uint32, limit int, explicitExcludeL2BlockNumbers []uint32) ([]bfgd.L2BTCFinality, error) { + sql := fmt.Sprintf(` + SELECT + NULL, + 0, + l2_keystones.l2_keystone_abrev_hash, + l2_keystones.l1_block_number, + l2_keystones.l2_block_number, + l2_keystones.parent_ep_hash, + l2_keystones.prev_keystone_ep_hash, + l2_keystones.state_root, + l2_keystones.ep_hash, + l2_keystones.version, + %s, + COALESCE((SELECT MAX(height) FROM btc_blocks_can),0) + + FROM l2_keystones + WHERE l2_block_number != ANY($3) + AND l2_block_number <= $1 + ORDER BY l2_block_number DESC LIMIT $2 + `, effectiveHeightSql) + + rows, err := p.db.QueryContext( + ctx, + sql, + lessThanL2BlockNumber, + limit, + pq.Array(explicitExcludeL2BlockNumbers), + ) + if err != nil { + return nil, err + } + + defer rows.Close() + + finalities := []bfgd.L2BTCFinality{} + + for rows.Next() { + var l2BtcFinality bfgd.L2BTCFinality + err = rows.Scan( + &l2BtcFinality.BTCPubHeaderHash, + &l2BtcFinality.BTCPubHeight, + &l2BtcFinality.L2Keystone.Hash, + &l2BtcFinality.L2Keystone.L1BlockNumber, + &l2BtcFinality.L2Keystone.L2BlockNumber, + &l2BtcFinality.L2Keystone.ParentEPHash, + &l2BtcFinality.L2Keystone.PrevKeystoneEPHash, + &l2BtcFinality.L2Keystone.StateRoot, + &l2BtcFinality.L2Keystone.EPHash, + &l2BtcFinality.L2Keystone.Version, + &l2BtcFinality.EffectiveHeight, + &l2BtcFinality.BTCTipHeight, + ) + if err != nil { + return nil, err + } + l2BtcFinality.BTCPubHeight = -1 + finalities = append(finalities, l2BtcFinality) + } + + if rows.Err() != nil { + return nil, rows.Err() + } + + return finalities, nil +} + +// L2BTCFinalityMostRecent gets the most recent L2BtcFinalities sorted +// descending by l2_block_number +func (p *pgdb) L2BTCFinalityMostRecent(ctx context.Context, limit uint32) ([]bfgd.L2BTCFinality, error) { + if limit > 100 { + return nil, fmt.Errorf( + "limit cannot be greater than 100, received %d", + limit, + ) + } + + tip, err := p.canonicalChainTipL2BlockNumber(ctx) + if err != nil { + return nil, err + } + + // we found no canonical tip, return nothing + if tip == nil { + return []bfgd.L2BTCFinality{}, nil + } + + finalities := []bfgd.L2BTCFinality{} + + // first, get all of the most recent published finalities up to the limit + // from the tip + publishedFinalities, err := p.nextL2BTCFinalitiesPublished( + ctx, + *tip, + int(limit), + ) + if err != nil { + return nil, err + } + pfi := 0 + + // it is possible that there will be some unpublished finalities between + // the published + // ones, get all finalities up to the limit that are NOT in published. + // IMPORTANT NOTE: we call these explicity "assumed unpublished" + // instead of explicity looking for unpublished + // finalities, because a finality could get published between these two + // queries. this is why we call these "assumed". the idea is to make + // this worst-case scenario slighty out-of-date, rather than incorrect + excludeL2BlockNumbers := []uint32{} + for _, v := range publishedFinalities { + excludeL2BlockNumbers = append( + excludeL2BlockNumbers, + v.L2Keystone.L2BlockNumber, + ) + } + + unpublishedFinalities, err := p.nextL2BTCFinalitiesAssumedUnpublished( + ctx, + *tip, + int(limit), + excludeL2BlockNumbers) + if err != nil { + return nil, err + } + + for { + + var publishedFinality *bfgd.L2BTCFinality + if pfi < len(publishedFinalities) { + publishedFinality = &publishedFinalities[pfi] + } + + var finality *bfgd.L2BTCFinality + + var unpublishedFinality *bfgd.L2BTCFinality + for _, u := range unpublishedFinalities { + if u.L2Keystone.L2BlockNumber <= *tip { + unpublishedFinality = &u + break + } + } + + if publishedFinality == nil { + finality = unpublishedFinality + } else if unpublishedFinality == nil { + finality = publishedFinality + pfi++ + } else if publishedFinality.L2Keystone.L2BlockNumber >= + unpublishedFinality.L2Keystone.L2BlockNumber { + finality = publishedFinality + pfi++ + } else { + finality = unpublishedFinality + } + + // if we couldn't find finality, there are no more possibilities + if finality == nil { + break + } + + finalities = append(finalities, *finality) + if uint32(len(finalities)) >= limit { + break + } + + if finality.L2Keystone.L2BlockNumber == 0 { + break + } + + *tip = finality.L2Keystone.L2BlockNumber - 1 + } + + return finalities, nil +} + +// L2BTCFinalityByL2KeystoneAbrevHash queries for finalities by L2KeystoneAbrevHash +// and returns them descending by l2_block_number +func (p *pgdb) L2BTCFinalityByL2KeystoneAbrevHash(ctx context.Context, l2KeystoneAbrevHashes []database.ByteArray) ([]bfgd.L2BTCFinality, error) { + log.Tracef("L2BTCFinalityByL2KeystoneAbrevHash") + defer log.Tracef("L2BTCFinalityByL2KeystoneAbrevHash exit") + + if len(l2KeystoneAbrevHashes) > 100 { + return nil, errors.New("l2KeystoneAbrevHashes cannot be longer than 100") + } + + sql := fmt.Sprintf(` + SELECT + btc_blocks_can.hash, + COALESCE(btc_blocks_can.height, 0), + l2_keystones.l2_keystone_abrev_hash, + l2_keystones.l1_block_number, + l2_keystones.l2_block_number, + l2_keystones.parent_ep_hash, + l2_keystones.prev_keystone_ep_hash, + l2_keystones.state_root, + l2_keystones.ep_hash, + l2_keystones.version, + %s, + COALESCE((SELECT MAX(height) FROM btc_blocks_can),0) + + FROM l2_keystones + LEFT JOIN pop_basis ON l2_keystones.l2_keystone_abrev_hash + = pop_basis.l2_keystone_abrev_hash + LEFT JOIN btc_blocks_can ON pop_basis.btc_block_hash + = btc_blocks_can.hash + + WHERE l2_keystones.l2_keystone_abrev_hash = ANY($1) + + ORDER BY l2_keystones.l2_block_number DESC + `, effectiveHeightSql) + + l2KeystoneAbrevHashesStr := [][]byte{} + for _, l := range l2KeystoneAbrevHashes { + l2KeystoneAbrevHashesStr = append(l2KeystoneAbrevHashesStr, []byte(l)) + } + + // XXX this doesn't go here + log.Infof("the hashes are %v", l2KeystoneAbrevHashesStr) + + rows, err := p.db.QueryContext(ctx, sql, pq.Array(l2KeystoneAbrevHashesStr)) + if err != nil { + return nil, err + } + + defer rows.Close() + + finalities := []bfgd.L2BTCFinality{} + + for rows.Next() { + var l2BtcFinality bfgd.L2BTCFinality + err = rows.Scan( + &l2BtcFinality.BTCPubHeaderHash, + &l2BtcFinality.BTCPubHeight, + &l2BtcFinality.L2Keystone.Hash, + &l2BtcFinality.L2Keystone.L1BlockNumber, + &l2BtcFinality.L2Keystone.L2BlockNumber, + &l2BtcFinality.L2Keystone.ParentEPHash, + &l2BtcFinality.L2Keystone.PrevKeystoneEPHash, + &l2BtcFinality.L2Keystone.StateRoot, + &l2BtcFinality.L2Keystone.EPHash, + &l2BtcFinality.L2Keystone.Version, + &l2BtcFinality.EffectiveHeight, + &l2BtcFinality.BTCTipHeight, + ) + if err != nil { + return nil, err + } + + if l2BtcFinality.BTCPubHeaderHash == nil { + l2BtcFinality.BTCPubHeight = -1 + } + finalities = append(finalities, l2BtcFinality) + } + + if rows.Err() != nil { + return nil, rows.Err() + } + + return finalities, nil +} + +// BtcBlockCanonicalHeight returns the highest height of btc blocks on the +// canonical chain +func (p *pgdb) BtcBlockCanonicalHeight(ctx context.Context) (uint64, error) { + log.Tracef("BtcBlockCanonicalHeight") + defer log.Tracef("BtcBlockCanonicalHeight exit") + + sql := ` + SELECT COALESCE(MAX(height),0) + FROM btc_blocks_can + ` + + rows, err := p.db.QueryContext(ctx, sql) + if err != nil { + return 0, err + } + + defer rows.Close() + + for rows.Next() { + var result uint64 + err = rows.Scan(&result) + if err != nil { + return 0, err + } + + return result, nil + } + + if err = rows.Err(); err != nil { + return 0, err + } + + return 0, errors.New("should not get here") +} + +func (p *pgdb) AccessPublicKeyInsert(ctx context.Context, publicKey *bfgd.AccessPublicKey) error { + log.Tracef("AccessPublicKeyInsert") + defer log.Tracef("AccessPublicKeyInsert exit") + + const sql = ` + INSERT INTO access_public_keys ( + public_key + ) VALUES ($1) + ` + + _, err := p.db.ExecContext(ctx, sql, publicKey.PublicKey) + if err != nil { + pqErr := err.(*pq.Error) + if pqErr.Constraint == "access_public_keys_pkey" { + return database.DuplicateError("public key already exists") + } + + return err + } + + return nil +} + +func (p *pgdb) AccessPublicKeyExists(ctx context.Context, publicKey *bfgd.AccessPublicKey) (bool, error) { + log.Tracef("AccessPublicKeyExists") + defer log.Tracef("AccessPublicKeyExists exit") + + const sql = ` + SELECT EXISTS ( + SELECT * FROM access_public_keys WHERE public_key = $1 + ) + ` + + rows, err := p.db.QueryContext(ctx, sql, publicKey.PublicKey) + if err != nil { + return false, err + } + + defer rows.Close() + + for rows.Next() { + var exists bool + err = rows.Scan(&exists) + if err != nil { + return false, err + } + + return exists, nil + } + + if err = rows.Err(); err != nil { + return false, err + } + + return false, errors.New("should not get here") +} + +func (p *pgdb) AccessPublicKeyDelete(ctx context.Context, publicKey *bfgd.AccessPublicKey) error { + log.Tracef("AccessPublicKeyDelete") + log.Tracef("AccessPublicKeyDelete exit") + + sql := fmt.Sprintf(` + WITH deleted AS ( + DELETE FROM access_public_keys WHERE public_key = $1 + RETURNING * + ) SELECT count(*) FROM deleted; + `) + + rows, err := p.db.QueryContext(ctx, sql, publicKey.PublicKey) + if err != nil { + return err + } + + for rows.Next() { + var count int + if err := rows.Scan(&count); err != nil { + return err + } + + return database.NotFoundError("public key not found") + } + + if err := rows.Err(); err != nil { + return err + } + + return nil +} + +// canonicalChainTipL2BlockNumber gets our best guess of the canonical tip +// and returns it. it finds the highest btc block with an associated +// l2 keystone where only 1 btc block exists at that height +func (p *pgdb) canonicalChainTipL2BlockNumber(ctx context.Context) (*uint32, error) { + log.Tracef("canonicalChainTipL2BlockNumber") + defer log.Tracef("canonicalChainTipL2BlockNumber exit") + + sql := fmt.Sprintf(` + SELECT l2_keystones.l2_block_number + + FROM btc_blocks_can + + INNER JOIN pop_basis ON pop_basis.btc_block_hash = btc_blocks_can.hash + INNER JOIN l2_keystones ON l2_keystones.l2_keystone_abrev_hash + = pop_basis.l2_keystone_abrev_hash + + ORDER BY l2_block_number DESC LIMIT 1 + `) + + rows, err := p.db.QueryContext(ctx, sql) + if err != nil { + return nil, err + } + + defer rows.Close() + + for rows.Next() { + var l2BlockNumber uint32 + err := rows.Scan(&l2BlockNumber) + if err != nil { + return nil, err + } + + return &l2BlockNumber, nil + } + + if rows.Err() != nil { + return nil, rows.Err() + } + + return nil, nil +} + +func (p *pgdb) refreshBTCBlocksCanonical(ctx context.Context) error { + // XXX this probably should be REFRESH MATERIALIZED VIEW CONCURRENTLY + // however, this is more testable at the moment and we're in a time crunch, + // this works + sql := "REFRESH MATERIALIZED VIEW btc_blocks_can" + _, err := p.db.ExecContext(ctx, sql) + if err != nil { + return err + } + + return nil +} diff --git a/database/bfgd/scripts/0001.sql b/database/bfgd/scripts/0001.sql new file mode 100644 index 00000000..f7796c95 --- /dev/null +++ b/database/bfgd/scripts/0001.sql @@ -0,0 +1,64 @@ +BEGIN; + +-- Create database version +CREATE TABLE version (version INTEGER UNIQUE NOT NULL); + +-- Populate version +INSERT INTO version (version) VALUES (1); + +-- Create L2 keystone table +CREATE TABLE l2_keystones( + l2_keystone_abrev_hash BYTEA PRIMARY KEY NOT NULL, + version INTEGER NOT NULL, + l1_block_number BIGINT NOT NULL, + l2_block_number BIGINT NOT NULL, + parent_ep_hash BYTEA NOT NULL, + prev_keystone_ep_hash BYTEA NOT NULL, + state_root BYTEA NOT NULL, + ep_hash BYTEA NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP, + + CONSTRAINT l2_keystone_abrev_hash_length CHECK (octet_length(l2_keystone_abrev_hash) = 32), + CONSTRAINT parent_ep_hash_length CHECK (octet_length(parent_ep_hash) = 32), + CONSTRAINT prev_keystone_ep_hash_length CHECK (octet_length( prev_keystone_ep_hash) = 32), + CONSTRAINT state_root_length CHECK (octet_length(state_root) = 32), + CONSTRAINT ep_hash_length CHECK (octet_length(ep_hash) = 32) +); + +-- Create btc blocks table +CREATE TABLE btc_blocks( + hash BYTEA PRIMARY KEY UNIQUE NOT NULL, + header BYTEA NOT NULL, + height BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP, + + CONSTRAINT btc_blocks_hash UNIQUE (hash, header), + CONSTRAINT hash_length CHECK (octet_length(hash) = 32), + CONSTRAINT header_length CHECK (octet_length(header) = 80) +); + +-- Create pop data table +CREATE TABLE pop_basis( + id BIGSERIAL PRIMARY KEY NOT NULL, + btc_txid BYTEA NOT NULL, + btc_raw_tx BYTEA NOT NULL, + btc_block_hash BYTEA REFERENCES btc_blocks(hash) DEFAULT NULL, + btc_tx_index BIGINT DEFAULT NULL, + btc_merkle_path JSON DEFAULT NULL, + pop_txid BYTEA DEFAULT NULL, + l2_keystone_abrev_hash BYTEA NOT NULL, + pop_miner_public_key BYTEA NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP, + + UNIQUE (btc_txid, btc_raw_tx, btc_block_hash, btc_tx_index), + + CONSTRAINT btc_txid_length CHECK (octet_length(btc_txid) = 32), + CONSTRAINT pop_txid_length CHECK (pop_txid IS NOT NULL OR octet_length(pop_txid) = 32) +); + +CREATE UNIQUE INDEX btc_txid_unconfirmed ON pop_basis (btc_txid) WHERE (btc_block_hash IS NULL); + +COMMIT; diff --git a/database/bfgd/scripts/0002.sql b/database/bfgd/scripts/0002.sql new file mode 100644 index 00000000..cf52cafe --- /dev/null +++ b/database/bfgd/scripts/0002.sql @@ -0,0 +1,48 @@ +BEGIN; + +UPDATE version SET version = 2; + +-- this materialized view represents the canonical btc_blocks as we know it +CREATE MATERIALIZED VIEW btc_blocks_can AS + +WITH RECURSIVE bb AS ( + SELECT hash, header, height FROM btc_blocks + WHERE height = ( + SELECT MAX(height) as height + FROM __highest + WHERE c = 1 + ) + + UNION + + SELECT + btc_blocks.hash, + btc_blocks.header, + btc_blocks.height + FROM btc_blocks, bb + WHERE + + -- try to find the parent block via header -> parent hash + ( + substr(bb.header, 5, 32) = btc_blocks.hash + AND bb.height > btc_blocks.height + ) + OR + -- OR find one of the next highest btc_blocks we know about, + -- we may be missing a block this connects that + btc_blocks.hash = ( + SELECT hash FROM btc_blocks + WHERE height < bb.height ORDER BY height DESC LIMIT 1) + ), __highest AS ( + SELECT height, count(*) AS c + FROM btc_blocks + GROUP BY height + ) +SELECT * FROM bb; + +CREATE INDEX height_idx on btc_blocks (height DESC); +CREATE INDEX btc_block_hash_idx on pop_basis (btc_block_hash); +CREATE INDEX l2_keystone_abrev_hash_idx on pop_basis (l2_keystone_abrev_hash); +CREATE INDEX l2_keystone_l2_block_number_idx on l2_keystones (l2_block_number); + +COMMIT; diff --git a/database/bfgd/scripts/0003.sql b/database/bfgd/scripts/0003.sql new file mode 100644 index 00000000..d8430b57 --- /dev/null +++ b/database/bfgd/scripts/0003.sql @@ -0,0 +1,10 @@ +BEGIN; + +UPDATE version SET version = 3; + +CREATE TABLE access_public_keys ( + public_key BYTEA NOT NULL PRIMARY KEY CHECK (LENGTH(public_key) = 33), + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +COMMIT; diff --git a/database/bfgd/scripts/0004.sql b/database/bfgd/scripts/0004.sql new file mode 100644 index 00000000..bd29efe6 --- /dev/null +++ b/database/bfgd/scripts/0004.sql @@ -0,0 +1,34 @@ +BEGIN; + +UPDATE version SET version = 4; + +-- Notification stored procedure +CREATE FUNCTION notify_event() RETURNS TRIGGER AS $$ + DECLARE + data_old json; + data_new json; + notification json; + + BEGIN + data_old = row_to_json(OLD); + data_new = row_to_json(NEW); + + -- Contruct the notification as a JSON string. + notification = json_build_object( + 'table', TG_TABLE_NAME, + 'action', TG_OP, + 'data_new', data_new, + 'data_old', data_old); + + -- Execute pg_notify(channel, notification) + PERFORM pg_notify('events', notification::text); + + -- Result is ignored since this is an AFTER trigger + RETURN NULL; + END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER btc_blocks_event AFTER INSERT OR UPDATE + ON btc_blocks FOR EACH ROW EXECUTE PROCEDURE notify_event(); + +COMMIT; diff --git a/database/bfgd/scripts/0005.sql b/database/bfgd/scripts/0005.sql new file mode 100644 index 00000000..a5c02e74 --- /dev/null +++ b/database/bfgd/scripts/0005.sql @@ -0,0 +1,29 @@ +BEGIN; + +UPDATE version SET version = 5; + +CREATE FUNCTION refresh_btc_blocks_can() + RETURNS TRIGGER + LANGUAGE PLPGSQL +AS +$$ +BEGIN + REFRESH MATERIALIZED VIEW btc_blocks_can; + RETURN NEW; +END; +$$; + +CREATE TRIGGER btc_blocks_canonical_refresh_btc_blocks AFTER INSERT OR UPDATE + ON btc_blocks FOR EACH STATEMENT EXECUTE PROCEDURE refresh_btc_blocks_can(); + +CREATE TRIGGER btc_blocks_canonical_refresh_l2_keystones AFTER INSERT OR UPDATE + ON l2_keystones FOR EACH STATEMENT EXECUTE PROCEDURE refresh_btc_blocks_can(); + +CREATE TRIGGER btc_blocks_canonical_refresh_pop_basis AFTER INSERT OR UPDATE + ON pop_basis FOR EACH STATEMENT EXECUTE PROCEDURE refresh_btc_blocks_can(); + + +CREATE TRIGGER access_public_keys_delete AFTER DELETE + ON access_public_keys FOR EACH ROW EXECUTE PROCEDURE notify_event(); + +COMMIT; diff --git a/database/bfgd/scripts/0006.sql b/database/bfgd/scripts/0006.sql new file mode 100644 index 00000000..bffeb0dc --- /dev/null +++ b/database/bfgd/scripts/0006.sql @@ -0,0 +1,8 @@ +BEGIN; + +UPDATE version SET version = 6; + +CREATE TRIGGER l2_keystones_changed AFTER INSERT + ON l2_keystones FOR EACH STATEMENT EXECUTE PROCEDURE notify_event(); + +COMMIT; \ No newline at end of file diff --git a/database/bfgd/scripts/createdb.sh b/database/bfgd/scripts/createdb.sh new file mode 100755 index 00000000..cb839c46 --- /dev/null +++ b/database/bfgd/scripts/createdb.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# +# Copyright (c) 2024 Hemi Labs, Inc. +# Use of this source code is governed by the MIT License, +# which can be found in the LICENSE file. +# + +. ./db.sh + +createdb diff --git a/database/bfgd/scripts/db.sh b/database/bfgd/scripts/db.sh new file mode 100644 index 00000000..84f8e8e1 --- /dev/null +++ b/database/bfgd/scripts/db.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# +# Copyright (c) 2024 Hemi Labs, Inc. +# Use of this source code is governed by the MIT License, +# which can be found in the LICENSE file. +# + +if [ -z ${PSQL} ]; then + PSQL=psql +fi + +if [ -z ${DBNAME} ]; then + DBNAME=bfgdb +fi + +. ../../scripts/db.sh diff --git a/database/bfgd/scripts/dropdb.sh b/database/bfgd/scripts/dropdb.sh new file mode 100755 index 00000000..3d14f644 --- /dev/null +++ b/database/bfgd/scripts/dropdb.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# +# Copyright (c) 2024 Hemi Labs, Inc. +# Use of this source code is governed by the MIT License, +# which can be found in the LICENSE file. +# + +. ./db.sh + +dropdb diff --git a/database/bfgd/scripts/populatedb.sh b/database/bfgd/scripts/populatedb.sh new file mode 100755 index 00000000..56690886 --- /dev/null +++ b/database/bfgd/scripts/populatedb.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# +# Copyright (c) 2024 Hemi Labs, Inc. +# Use of this source code is governed by the MIT License, +# which can be found in the LICENSE file. +# + +. ./db.sh + +populatedb diff --git a/database/bfgd/scripts/upgradedb.sh b/database/bfgd/scripts/upgradedb.sh new file mode 100755 index 00000000..9d45a4f8 --- /dev/null +++ b/database/bfgd/scripts/upgradedb.sh @@ -0,0 +1,10 @@ +#/bin/sh +# +# Copyright (c) 2024 Hemi Labs, Inc. +# Use of this source code is governed by the MIT License, +# which can be found in the LICENSE file. +# + +. ./db.sh + +upgradedb diff --git a/database/database.go b/database/database.go new file mode 100644 index 00000000..5e61a653 --- /dev/null +++ b/database/database.go @@ -0,0 +1,356 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package database + +import ( + "context" + "database/sql/driver" + "encoding/hex" + "errors" + "fmt" + "math/big" + "strconv" + "strings" + "time" +) + +type Database interface { + Close() error // Close database + + // SQL + RegisterNotification(context.Context, NotificationName, NotificationCallback, any) error + UnregisterNotification(NotificationName) error +} + +type NotFoundError string + +func (nfe NotFoundError) Error() string { + return string(nfe) +} + +func (nfe NotFoundError) Is(target error) bool { + _, ok := target.(NotFoundError) + return ok +} + +type DuplicateError string + +func (de DuplicateError) Error() string { + return string(de) +} + +func (de DuplicateError) Is(target error) bool { + _, ok := target.(DuplicateError) + return ok +} + +type ValidationError string + +func (ve ValidationError) Error() string { + return string(ve) +} + +func (ve ValidationError) Is(target error) bool { + _, ok := target.(ValidationError) + return ok +} + +var ( + ErrDuplicate = DuplicateError("duplicate") + ErrNotFound = NotFoundError("not found") + ErrValidation = ValidationError("validation") +) + +// ByteArray is a type that corresponds to BYTEA in a database. It supports +// marshalling and unmarshalling from JSON, as well as implementing the +// sql.Scanner interface with NULL handling. +type ByteArray []byte + +func (ba ByteArray) String() string { + return hex.EncodeToString([]byte(ba)) +} + +func (ba *ByteArray) MarshalJSON() ([]byte, error) { + if *ba == nil { + return []byte("null"), nil + } + return []byte(fmt.Sprintf("\"\\\\x%s\"", hex.EncodeToString([]byte(*ba)))), nil +} + +func (ba *ByteArray) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + *ba = nil + return nil + } + // We expect a quoted escape prefixed (\\x) hexadecimal string. + s := string(data) + if !strings.HasPrefix(s, `"\\x`) || !strings.HasSuffix(s, `"`) { + return errors.New("byte array does not have escape prefix") + } + b, err := hex.DecodeString(s[4 : len(s)-1]) + if err != nil { + return err + } + *ba = b + return nil +} + +func (ba *ByteArray) Scan(value interface{}) error { + if value == nil { + *ba = nil + return nil + } + b, ok := value.([]byte) + if !ok { + return fmt.Errorf("not a byte array (%T)", value) + } + nba := make([]byte, len(b)) + copy(nba, b) + *ba = nba + return nil +} + +func (ba ByteArray) Value() (driver.Value, error) { + if ba == nil { + return nil, nil + } + return []byte(ba), nil +} + +//// XXX figure out why this doens't work +//func (ba *ByteArray) Value() (driver.Value, error) { +// return *ba, nil +//} + +var _ driver.Valuer = (*ByteArray)(nil) + +// BigInt is a large integer data type that corresponds to a NUMERIC in +// a database. +type BigInt struct { + *big.Int +} + +func NewBigInt(bi *big.Int) *BigInt { + return &BigInt{Int: bi} +} + +func NewBigIntZero() *BigInt { + return &BigInt{Int: new(big.Int)} +} + +func (bi *BigInt) Cmp(a *BigInt) int { + return bi.Int.Cmp(a.Int) +} + +func (bi *BigInt) IsZero() bool { + return bi.IsInt64() && bi.Int64() == 0 +} + +func (bi *BigInt) SetUint64(val uint64) *BigInt { + if bi.Int == nil { + bi.Int = new(big.Int) + } + bi.Int.SetUint64(val) + return bi +} + +func (bi *BigInt) MarshalJSON() ([]byte, error) { + if bi.Int == nil { + return []byte("null"), nil + } + return bi.Int.MarshalJSON() +} + +func (bi *BigInt) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + bi.Int = nil + return nil + } + nbi := new(big.Int) + if err := nbi.UnmarshalJSON(data); err != nil { + return err + } + bi.Int = nbi + return nil +} + +func (bi *BigInt) Scan(value interface{}) error { + if value == nil { + bi.Int = nil + return nil + } + + b, ok := value.([]byte) + if !ok { + return fmt.Errorf("not a byte array (%T)", value) + } + nbi := new(big.Int) + if _, ok := nbi.SetString(string(b), 10); !ok { + return fmt.Errorf("failed to convert %q to BigInt", string(b)) + } + bi.Int = nbi + return nil +} + +// bi should not be a pointer but it seems like we are pleasing the Valuer +// interface. This needs some additional testing. +func (bi *BigInt) Value() (driver.Value, error) { + if bi == nil || bi.Int == nil { + return nil, nil + } + return bi.Text(10), nil +} + +var _ driver.Valuer = (*BigInt)(nil) + +// Timestamp is a type that corresponds to a TIMESTAMP in a database. It +// supports marshalling and unmarshalling from JSON, as well as implementing +// the sql.Scanner interface with NULL handling. +type Timestamp struct { + time.Time +} + +const timestampFormat = `2006-01-02T15:04:05.999999999` + +// NewTimestamp returns a Timestamp initialized with the given time. +func NewTimestamp(time time.Time) Timestamp { + return Timestamp{Time: time} +} + +func (ts Timestamp) MarshalJSON() ([]byte, error) { + if ts.IsZero() { + return []byte("null"), nil + } + return []byte(ts.Format(`"` + timestampFormat + `"`)), nil +} + +func (ts *Timestamp) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + ts.Time = time.Time{} + return nil + } + var err error + ts.Time, err = time.Parse(`"`+timestampFormat+`"`, string(data)) + if err != nil { + return err + } + return nil +} + +func (ts *Timestamp) Scan(value interface{}) error { + if value == nil { + ts.Time = time.Time{} + return nil + } + var ok bool + ts.Time, ok = value.(time.Time) + if !ok { + return fmt.Errorf("not a time (%T)", value) + } + return nil +} + +func (ts Timestamp) Value() (driver.Value, error) { + if ts.IsZero() { + return nil, nil + } + return ts.Time.Format(time.RFC3339Nano), nil +} + +var _ driver.Valuer = (*Timestamp)(nil) + +// TimeZone is a type that encodes to and decodes from a +/-hh:mm string. +type TimeZone struct { + hour int + minute int + valid bool +} + +func (tz TimeZone) Equal(tzb TimeZone) bool { + return tz.hour == tzb.hour && tz.minute == tzb.minute +} + +func (tz *TimeZone) Parse(s string) error { + if s == "" { + tz.hour, tz.minute, tz.valid = 0, 0, false + } + if len(s) != len("+10:00") { + return fmt.Errorf("%q has invalid length", s) + } + if s[0] != '-' && s[0] != '+' { + return fmt.Errorf("invalid prefix %q (not +/-)", s[0]) + } + if s[3] != ':' { + return fmt.Errorf("invalid separator %q (not :)", s[3]) + } + + hour, err := strconv.Atoi(s[0:3]) + if err != nil || hour < -12 || hour > 14 { + return fmt.Errorf("invalid hour %q", s[0:3]) + } + minute, err := strconv.Atoi(s[4:6]) + if err != nil || minute < 0 || minute > 59 { + return fmt.Errorf("invalid minute %q", s[4:6]) + } + tz.hour, tz.minute, tz.valid = hour, minute, true + + return nil +} + +func (tz TimeZone) String() string { + return fmt.Sprintf("%+0.2d:%0.2d", tz.hour, tz.minute) +} + +func (tz TimeZone) MarshalJSON() ([]byte, error) { + if !tz.valid { + return []byte("null"), nil + } + return []byte(`"` + tz.String() + `"`), nil +} + +func (tz *TimeZone) UnmarshalJSON(data []byte) error { + s := string(data) + if strings.HasPrefix(s, `"`) && strings.HasSuffix(s, `"`) { + s = s[1 : len(s)-1] + } + if s == "null" { + tz.hour, tz.minute, tz.valid = 0, 0, false + return nil + } + if err := tz.Parse(s); err != nil { + return fmt.Errorf("invalid timezone: %v", err) + } + return nil +} + +func (tz *TimeZone) Scan(value interface{}) error { + if value == nil { + tz.hour, tz.minute, tz.valid = 0, 0, false + return nil + } + s, ok := value.(string) + if !ok { + return fmt.Errorf("not a string (%T)", value) + } + if err := tz.Parse(s); err != nil { + return fmt.Errorf("invalid timezone: %v", err) + } + return nil +} + +func (tz TimeZone) Value() (driver.Value, error) { + if !tz.valid { + return nil, nil + } + return tz.String(), nil +} + +var _ driver.Valuer = (*TimeZone)(nil) + +// NotificationCallback is a callback function for a database notification. +type NotificationCallback func(string, string, interface{}, interface{}) + +// NotificationName identifies a database notification type. +type NotificationName string diff --git a/database/database_test.go b/database/database_test.go new file mode 100644 index 00000000..afd13197 --- /dev/null +++ b/database/database_test.go @@ -0,0 +1,300 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package database + +import ( + "bytes" + "fmt" + "testing" + "time" +) + +func TestByteArrayJSON(t *testing.T) { + tests := []struct { + data []byte + want []byte + wantErr bool + }{ + { + data: []byte(`"\\x1234"`), + want: []byte{0x12, 0x34}, + }, + { + data: []byte(`"\\x0102030405060708090a0b0c0d0e0f01"`), + want: []byte{ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x01, + }, + }, + { + data: []byte("null"), + want: nil, + }, + { + data: []byte(`\\x1234`), + wantErr: true, + }, + { + data: []byte(`"1234"`), + wantErr: true, + }, + { + data: []byte(`""`), + wantErr: true, + }, + { + data: []byte(`"\x1"`), + wantErr: true, + }, + } + + for i, test := range tests { + t.Run(fmt.Sprintf("bytearray-%d", i), func(t *testing.T) { + var ba ByteArray + switch err := ba.UnmarshalJSON(test.data); { + case err != nil && !test.wantErr: + t.Errorf("UnmarshalJSON failed: %v", err) + case err == nil && test.wantErr: + t.Error("UnmarshalJSON succeeded, want error") + case err == nil && !test.wantErr: + if !bytes.Equal([]byte(ba), test.want) { + t.Errorf("UnmarshalJSON = %v, want %v", ba, test.want) + } + } + + if test.wantErr { + return + } + + b, err := ba.MarshalJSON() + if err != nil { + t.Fatalf("MarshalJSON failed: %v", err) + } + if !bytes.Equal(b, test.data) { + t.Errorf("MarshalJSON = %v, want %v", b, test.data) + } + }) + } +} + +func TestByteArrayScan(t *testing.T) { + tests := []struct { + src interface{} + want ByteArray + }{ + { + src: nil, + want: ByteArray(nil), + }, + { + src: []byte{}, + want: ByteArray{}, + }, + { + src: []byte{0x12, 0x34}, + want: ByteArray{0x12, 0x34}, + }, + } + for _, test := range tests { + var ba ByteArray + if err := ba.Scan(test.src); err != nil { + t.Fatalf("Failed to scan: %v", err) + } + if !bytes.Equal(ba, test.want) { + t.Errorf("Got %v, want %v", ba, test.want) + } + } +} + +func TestByteArrayScanReuse(t *testing.T) { + b := []byte{0x12, 0x34} + want := make([]byte, len(b)) + copy(want, b) + + var ba ByteArray + if err := ba.Scan(b); err != nil { + t.Fatalf("Failed to scan: %v", err) + } + b[0], b[1] = 0xff, 0xff + if !bytes.Equal(ba, want) { + t.Errorf("Got %v, want %v", ba, want) + } +} + +func TestTimestampJSON(t *testing.T) { + tests := []struct { + data []byte + want Timestamp + wantErr bool + }{ + { + data: []byte(`"2022-05-11T15:23:31.723583"`), + want: Timestamp{Time: time.Date(2022, 5, 11, 15, 23, 31, 723583000, time.UTC)}, + }, + { + data: []byte("null"), + want: Timestamp{}, + }, + { + data: []byte(`""`), + wantErr: true, + }, + { + data: []byte(`"2022-05-11"`), + wantErr: true, + }, + } + for i, test := range tests { + t.Run(fmt.Sprintf("timestamp-%d", i), func(t *testing.T) { + var ts Timestamp + err := ts.UnmarshalJSON(test.data) + switch { + case err != nil && !test.wantErr: + t.Errorf("UnmarshalJSON failed: %v", err) + case err == nil && test.wantErr: + t.Error("UnmarshalJSON succeeded, want error") + case err == nil && !test.wantErr: + if !ts.Equal(test.want.Time) { + t.Errorf("UnmarshalJSON = %v, want %v", ts, test.want) + } + } + + if test.wantErr { + return + } + + b, err := ts.MarshalJSON() + if err != nil { + t.Fatalf("MarshalJSON failed: %v", err) + } + if !bytes.Equal(b, test.data) { + t.Errorf("MarshalJSON = %v, want %v", b, test.data) + } + }) + } +} + +func TestTimestampScan(t *testing.T) { + tests := []struct { + src interface{} + want Timestamp + }{ + { + src: nil, + want: Timestamp{}, + }, + { + src: time.Date(2022, 5, 11, 15, 23, 31, 723583000, time.UTC), + want: Timestamp{Time: time.Date(2022, 5, 11, 15, 23, 31, 723583000, time.UTC)}, + }, + } + for _, test := range tests { + var ts Timestamp + if err := ts.Scan(test.src); err != nil { + t.Fatalf("Failed to scan: %v", err) + } + if !ts.Equal(test.want.Time) { + t.Errorf("Got %v, want %v", ts, test.want) + } + } +} + +func TestTimeZoneJSON(t *testing.T) { + tests := []struct { + data []byte + want TimeZone + wantErr bool + }{ + { + data: []byte(`"-09:30"`), + want: TimeZone{hour: -9, minute: 30}, + }, + { + data: []byte(`"+10:00"`), + want: TimeZone{hour: 10, minute: 0}, + }, + { + data: []byte("null"), + want: TimeZone{}, + }, + { + data: []byte(`""`), + wantErr: true, + }, + { + data: []byte(`"10:00"`), + wantErr: true, + }, + { + data: []byte(`"+9:00"`), + wantErr: true, + }, + } + for i, test := range tests { + t.Run(fmt.Sprintf("timestamp-%d", i), func(t *testing.T) { + var tz TimeZone + switch err := tz.UnmarshalJSON(test.data); { + case err == nil && test.wantErr: + t.Error("UnmarshalJSON succeeded, want error") + case err != nil && !test.wantErr: + t.Errorf("UnmarshalJSON failed: %v", err) + case err == nil && !test.wantErr: + if !tz.Equal(test.want) { + t.Errorf("UnmarshalJSON = %v, want %v", tz, test.want) + } + } + + if test.wantErr { + return + } + + b, err := tz.MarshalJSON() + if err != nil { + t.Fatalf("MarshalJSON failed: %v", err) + } + if !bytes.Equal(b, test.data) { + t.Errorf("MarshalJSON = %v, want %v", b, test.data) + } + }) + } +} + +func TestTimeZoneScan(t *testing.T) { + tests := []struct { + src interface{} + want TimeZone + wantErr bool + }{ + { + src: nil, + want: TimeZone{}, + }, + { + src: "-09:30", + want: TimeZone{hour: -9, minute: 30}, + }, + { + src: "+10:00", + want: TimeZone{hour: 10, minute: 0}, + }, + { + src: "10:00", + wantErr: true, + }, + } + for _, test := range tests { + var tz TimeZone + switch err := tz.Scan(test.src); { + case err == nil && test.wantErr: + t.Errorf("Got nil error for %q, want error", test.src) + case err != nil && !test.wantErr: + t.Errorf("Failed to scan %q: %v", test.src, err) + case err == nil && !test.wantErr: + if !tz.Equal(test.want) { + t.Errorf("Got %v, want %v", tz, test.want) + } + } + } +} diff --git a/database/postgres/postgres.go b/database/postgres/postgres.go new file mode 100644 index 00000000..2cf5e97e --- /dev/null +++ b/database/postgres/postgres.go @@ -0,0 +1,283 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package postgres + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "reflect" + "sync" + "time" + + "github.com/davecgh/go-spew/spew" + "github.com/juju/loggo" + "github.com/lib/pq" + + "github.com/hemilabs/heminetwork/database" +) + +const ( + logLevel = "INFO" + verbose = false +) + +var log = loggo.GetLogger("postgres") + +func init() { + loggo.ConfigureLoggers(logLevel) +} + +type psqlNotification struct { + name database.NotificationName // Expected notification type + callback database.NotificationCallback // Callback for notification + payload reflect.Type // Payload type +} + +type Database struct { + mtx sync.RWMutex + ntfn map[database.NotificationName]*psqlNotification // Notification handlers + + listener *pq.Listener // Postgres listener + listenerCloseCh chan struct{} // Postgres listener close channel + wg sync.WaitGroup // Wait group for notification handler exit + + uri string // database connection string + pool *sql.DB +} + +// Connect connects to a postgres database. This is only used in tests. +func Connect(ctx context.Context, uri string) (*sql.DB, error) { + pool, err := sql.Open("postgres", uri) + if err != nil { + return nil, fmt.Errorf("postgres open: %v", err) + } + if err := pool.PingContext(ctx); err != nil { + return nil, fmt.Errorf("unable to connect to database: %v", err) + } + return pool, nil +} + +func (p *Database) Close() error { + log.Tracef("Close") + defer log.Tracef("Close exit") + + p.mtx.Lock() + if p.listenerCloseCh != nil { + close(p.listenerCloseCh) + } + err := p.pool.Close() + p.mtx.Unlock() + + p.wg.Wait() + + return err +} + +func (p *Database) DB() *sql.DB { + log.Tracef("DB") + defer log.Tracef("DB exit") + return p.pool +} + +// ntfnWrapper is the data type of notifications that come from the database. +type ntfnWrapper struct { + Table string `json:"table"` // Table name + Action string `json:"action"` // Action that led to notification + DataNew json.RawMessage `json:"data_new"` // JSON payload for new row + DataOld json.RawMessage `json:"data_old"` // JSON payload for old row +} + +func (p *Database) ntfnEventHandler(pqn *pq.Notification) { + log.Tracef("ntfnEventHandler notify: %v", spew.Sdump(pqn)) + + // A nil notification can be received on database shutdown. + if pqn == nil { + return + } + + var nw ntfnWrapper + if err := json.Unmarshal([]byte(pqn.Extra), &nw); err != nil { + // We can't do much more than just logging it. + log.Errorf("ntfnEventHandler unmarshal error: %v", err) + return + } + // log.Tracef("%v", spew.Sdump(nw)) + + p.mtx.RLock() + pn, ok := p.ntfn[database.NotificationName(nw.Table)] + p.mtx.RUnlock() + + // This notification was for a table/event that we have not registered + // for, so ignore it. + if !ok { + return + } + + // Convert JSON to structure. + payloadNew := reflect.New(pn.payload).Interface() + if err := json.Unmarshal([]byte(nw.DataNew), &payloadNew); err != nil { + log.Errorf("ntfnEventHandler decode: %v", err) + return + } + + payloadOld := reflect.Zero(reflect.PtrTo(pn.payload)).Interface() + if len(nw.DataOld) > 0 && !bytes.Equal(nw.DataOld, []byte("null")) { + payloadOld = reflect.New(pn.payload).Interface() + if err := json.Unmarshal([]byte(nw.DataOld), &payloadOld); err != nil { + log.Errorf("ntfnEventHandler decode: %v", err) + return + } + } + + log.Debugf("ntfnEventHandler: calling callback %v %v", nw.Action, nw.Table) + log.Tracef("ntfnEventHandler: payload new %v", spew.Sdump(payloadNew)) + log.Tracef("ntfnEventHandler: payload old %v", spew.Sdump(payloadOld)) + + pn.callback(nw.Table, nw.Action, payloadNew, payloadOld) +} + +// ntfnListenHandler listens for database notifications. +func (p *Database) ntfnListenHandler(ctx context.Context) { + log.Tracef("ntfnListenHandler") + defer func() { + // Close listener + if err := p.listener.Close(); err != nil { + log.Errorf("ntfnListenHandler close listener: %v", err) + } + + p.mtx.Lock() + p.listener = nil + p.mtx.Unlock() + + p.wg.Done() + + log.Tracef("ntfnListenHandler exit") + }() + + for { + select { + case <-ctx.Done(): + log.Tracef("ntfnListenHandler: context done") + return + + case <-p.listenerCloseCh: + log.Tracef("ntfnListenHandler: closing listener") + return + + case pqn := <-p.listener.Notify: + // TODO: Consider limiting the number of notifications being + // processed at the same time. + go p.ntfnEventHandler(pqn) + + case <-time.After(60 * time.Second): + go func() { + // log.Tracef("ntfnHandler: ping") + p.listener.Ping() + }() + } + } +} + +// RegisterNotification registers a call back for database notifications. +// +// Note that this currently launches a connection+go routine to listen for +// notifications. It may be an idea to switch this code to only launch a single +// go routine for all notifications. +func (p *Database) RegisterNotification(ctx context.Context, n database.NotificationName, f database.NotificationCallback, payload any) error { + log.Tracef("RegisterNotification") + + p.mtx.Lock() + defer p.mtx.Unlock() + + if _, ok := p.ntfn[n]; ok { + return fmt.Errorf("notification already registered: %v", n) + } + + pn := &psqlNotification{ + name: n, + callback: f, + payload: reflect.TypeOf(payload), + } + log.Tracef("RegisterNotification: %v", n) + p.ntfn[n] = pn + + if p.listener == nil { + // XXX this might have to become a callback as well. + reportProblem := func(ev pq.ListenerEventType, err error) { + if err != nil { + log.Debugf("notification error %v", spew.Sdump(ev)) + log.Errorf("notification error (%v): %v", n, err) + } + } + + p.listener = pq.NewListener(p.uri, 10*time.Second, time.Minute, + reportProblem) + if err := p.listener.Listen("events"); err != nil { + return err + } + p.listenerCloseCh = make(chan struct{}) + + p.wg.Add(1) + go p.ntfnListenHandler(ctx) + } + + return nil +} + +func (p *Database) UnregisterNotification(n database.NotificationName) error { + log.Tracef("UnregisterNotification") + defer log.Tracef("UnregisterNotification exit") + + p.mtx.Lock() + defer p.mtx.Unlock() + + if _, ok := p.ntfn[n]; !ok { + return fmt.Errorf("handler not found: %v", n) + } + delete(p.ntfn, n) + + if len(p.ntfn) == 0 && p.listenerCloseCh != nil { + close(p.listenerCloseCh) + p.listenerCloseCh = nil + } + + return nil +} + +func New(ctx context.Context, puri string, version int) (*Database, error) { + log.Tracef("New") + defer log.Tracef("New exit") + + // Setup and connect to database. + pool, err := sql.Open("postgres", puri) + if err != nil { + return nil, fmt.Errorf("postgres open: %v", err) + } + pool.SetConnMaxLifetime(0) + pool.SetMaxIdleConns(5) + pool.SetMaxOpenConns(5) + if err := pool.PingContext(ctx); err != nil { + return nil, fmt.Errorf("unable to connect to database: %v", err) + } + + // Verify version. + const selectVersion = `SELECT * FROM version LIMIT 1;` + var dbVersion int + if err := pool.QueryRowContext(ctx, selectVersion).Scan(&dbVersion); err != nil { + return nil, err + } + if version != dbVersion { + return nil, fmt.Errorf("wrong database version: expected %v, got %v", version, dbVersion) + } + + return &Database{ + pool: pool, + uri: puri, + ntfn: make(map[database.NotificationName]*psqlNotification), + }, nil +} diff --git a/database/scripts/db.sh b/database/scripts/db.sh new file mode 100644 index 00000000..32de4596 --- /dev/null +++ b/database/scripts/db.sh @@ -0,0 +1,82 @@ +#!/bin/sh +# +# Copyright (c) 2024 Hemi Labs, Inc. +# Use of this source code is governed by the MIT License, +# which can be found in the LICENSE file. +# + +set -e +set -u +#set -x + +if [ -z "${PSQL:-}" ]; then + PSQL=psql +fi + +if [ -z "${DBUSER:-}" ]; then + DBUSER=$(whoami) +fi + +if [ -z "${DBNAME:-}" ]; then + echo "must provide DBNAME" + exit 1 +fi + + +if [ -z "${DBSOCKET:-}" ]; then + DBSOCKET="" +fi + +psqlexecute() { + local dbname=$1 + shift 1 + DATABASE_URL="postgres://${DBUSER}@:/${dbname}?host=${DBSOCKET}" + ${PSQL} ${DATABASE_URL} "$@" +} + +applysql() { + for sqlfile in ${@}; do + echo "Applying $sqlfile" + psqlexecute ${DBNAME} -f ${sqlfile} + done +} + +createdb() { + echo "Creating database ${DBNAME}" + psqlexecute postgres -c "CREATE DATABASE ${DBNAME};" + upgradedb +} + +dropdb() { + echo "Dropping database ${DBNAME}" + psqlexecute postgres -c "DROP DATABASE ${DBNAME};" +} + +populatedb() { + SQLFILES=$(ls testdata/*.sql | sort -n) + applysql ${SQLFILES} +} + +upgradedb() { + echo "Upgrading database..." + local dbexists=$(psqlexecute template1 -t -c "SELECT 'exists' FROM pg_database WHERE datname='${DBNAME}'" | head -n 1 | sed 's/\s//g') + if [ -z "${dbexists}" ]; then + echo "Database '${DBNAME}' does not exist, aborting..." + return + fi + local exists=$(psqlexecute ${DBNAME} -t -c "SELECT 'exists' FROM pg_tables WHERE tablename = 'version'" | head -n 1 | sed 's/\s//g') + version=0 + if [ -n "${exists}" ]; then + version=$(psqlexecute ${DBNAME} -t -c "SELECT version FROM version;" | head -n 1 | sed 's/\s//g') + fi + echo "Current version: $version" + + SQLFILES=$(ls *.sql | sort -n) + for sqlfile in ${SQLFILES}; do + fv=$(echo $sqlfile | cut -d. -f1) + if [ $version -lt $fv ]; then + echo "Applying $sqlfile" + applysql $sqlfile + fi + done +} diff --git a/docker/bfgd/Dockerfile b/docker/bfgd/Dockerfile new file mode 100644 index 00000000..75b8c21a --- /dev/null +++ b/docker/bfgd/Dockerfile @@ -0,0 +1,63 @@ +# Build stage +FROM golang:1.22.0-alpine@sha256:8e96e6cff6a388c2f70f5f662b64120941fcd7d4b89d62fec87520323a316bd9 as builder + +# Add ca-certificates, timezone data, make and git +RUN apk --no-cache add --update ca-certificates tzdata make git + +# Create non-root user +RUN addgroup --gid 65532 bfgd && \ + adduser --disabled-password --gecos "" \ + --home "/etc/bfgd/" --shell "/sbin/nologin" \ + -G bfgd --uid 65532 bfgd + +WORKDIR /build/ +COPY . . + +RUN make deps +RUN GOOS=$(go env GOOS) GOARCH=$(go env GOARCH) CGO_ENABLED=0 GOGC=off make bfgd + +# Run stage +FROM scratch + +# Build metadata +ARG VERSION +ARG VCS_REF +ARG BUILD_DATE +LABEL org.opencontainers.image.created=$BUILD_DATE \ + org.opencontainers.image.authors="Hemi Labs" \ + org.opencontainers.image.url="https://github.com/hemilabs/heminetwork" \ + org.opencontainers.image.source="https://github.com/hemilabs/heminetwork" \ + org.opencontainers.image.version=$VERSION \ + org.opencontainers.image.revision=$VCS_REF \ + org.opencontainers.image.vendor="Hemi Labs" \ + org.opencontainers.image.licenses="MIT" \ + org.opencontainers.image.title="Bitcoin Finality Govener" \ + org.label-schema.build-date=$BUILD_DATE \ + org.label-schema.name="Bitcoin Finality Govener" \ + org.label-schema.url="https://github.com/hemilabs/heminetwork" \ + org.label-schema.vcs-url="https://github.com/hemilabs/heminetwork" \ + org.label-schema.vcs-ref=$VCS_REF \ + org.label-schema.vendor="Hemi Labs" \ + org.label-schema.version=$VERSION \ + org.label-schema.schema-version="1.0" + +# Copy files +COPY --from=builder /etc/group /etc/group +COPY --from=builder /etc/passwd /etc/passwd +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo +COPY --from=builder /build/bin/bfgd /usr/local/bin/bfgd + +# Environment variables +ENV BFG_EXBTC_ADDRESS= +ENV BFG_PUBLIC_KEY_AUTH= +ENV BFG_BTC_START_HEIGHT= +ENV BFG_LOG_LEVEL= +ENV BFG_POSTGRES_URI= +ENV BFG_PUBLIC_ADDRESS= +ENV BFG_PRIVATE_ADDRESS= +ENV BFG_PROMETHEUS_ADDRESS= + +USER bfgd:bfgd +WORKDIR /etc/bfgd/ +ENTRYPOINT ["/usr/local/bin/bfgd"] diff --git a/docker/bssd/Dockerfile b/docker/bssd/Dockerfile new file mode 100644 index 00000000..a16e322f --- /dev/null +++ b/docker/bssd/Dockerfile @@ -0,0 +1,59 @@ +# Build stage +FROM golang:1.22.0-alpine@sha256:8e96e6cff6a388c2f70f5f662b64120941fcd7d4b89d62fec87520323a316bd9 as builder + +# Add ca-certificates, timezone data, make and git +RUN apk --no-cache add --update ca-certificates tzdata make git + +# Create non-root user +RUN addgroup --gid 65532 bssd && \ + adduser --disabled-password --gecos "" \ + --home "/etc/bssd/" --shell "/sbin/nologin" \ + -G bssd --uid 65532 bssd + +WORKDIR /build/ +COPY . . + +RUN make deps +RUN GOOS=$(go env GOOS) GOARCH=$(go env GOARCH) CGO_ENABLED=0 GOGC=off make bssd + +# Run stage +FROM scratch + +# Build metadata +ARG VERSION +ARG VCS_REF +ARG BUILD_DATE +LABEL org.opencontainers.image.created=$BUILD_DATE \ + org.opencontainers.image.authors="Hemi Labs" \ + org.opencontainers.image.url="https://github.com/hemilabs/heminetwork" \ + org.opencontainers.image.source="https://github.com/hemilabs/heminetwork" \ + org.opencontainers.image.version=$VERSION \ + org.opencontainers.image.revision=$VCS_REF \ + org.opencontainers.image.vendor="Hemi Labs" \ + org.opencontainers.image.licenses="MIT" \ + org.opencontainers.image.title="Bitcoin Secure Sequencer" \ + org.label-schema.build-date=$BUILD_DATE \ + org.label-schema.name="Bitcoin Secure Sequencer" \ + org.label-schema.url="https://github.com/hemilabs/heminetwork" \ + org.label-schema.vcs-url="https://github.com/hemilabs/heminetwork" \ + org.label-schema.vcs-ref=$VCS_REF \ + org.label-schema.vendor="Hemi Labs" \ + org.label-schema.version=$VERSION \ + org.label-schema.schema-version="1.0" + +# Copy files +COPY --from=builder /etc/group /etc/group +COPY --from=builder /etc/passwd /etc/passwd +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo +COPY --from=builder /build/bin/bssd /usr/local/bin/bssd + +# Environment variables +ENV BSS_BFG_URL= +ENV BSS_ADDRESS= +ENV BSS_LOG_LEVEL= +ENV BSS_PROMETHEUS_ADDRESS= + +USER bssd:bssd +WORKDIR /etc/bssd/ +ENTRYPOINT ["/usr/local/bin/bssd"] diff --git a/docker/popmd/Dockerfile b/docker/popmd/Dockerfile new file mode 100644 index 00000000..08c5707f --- /dev/null +++ b/docker/popmd/Dockerfile @@ -0,0 +1,60 @@ +# Build stage +FROM golang:1.22.0-alpine@sha256:8e96e6cff6a388c2f70f5f662b64120941fcd7d4b89d62fec87520323a316bd9 as builder + +# Add ca-certificates, timezone data, make and git +RUN apk --no-cache add --update ca-certificates tzdata make git + +# Create non-root user +RUN addgroup --gid 65532 popmd && \ + adduser --disabled-password --gecos "" \ + --home "/etc/popmd/" --shell "/sbin/nologin" \ + -G popmd --uid 65532 popmd + +WORKDIR /build/ +COPY . . + +RUN make deps +RUN GOOS=$(go env GOOS) GOARCH=$(go env GOARCH) CGO_ENABLED=0 GOGC=off make popmd + +# Run stage +FROM scratch + +# Build metadata +ARG VERSION +ARG VCS_REF +ARG BUILD_DATE +LABEL org.opencontainers.image.created=$BUILD_DATE \ + org.opencontainers.image.authors="Hemi Labs" \ + org.opencontainers.image.url="https://github.com/hemilabs/heminetwork" \ + org.opencontainers.image.source="https://github.com/hemilabs/heminetwork" \ + org.opencontainers.image.version=$VERSION \ + org.opencontainers.image.revision=$VCS_REF \ + org.opencontainers.image.vendor="Hemi Labs" \ + org.opencontainers.image.licenses="MIT" \ + org.opencontainers.image.title="PoP Miner" \ + org.label-schema.build-date=$BUILD_DATE \ + org.label-schema.name="PoP Miner" \ + org.label-schema.url="https://github.com/hemilabs/heminetwork" \ + org.label-schema.vcs-url="https://github.com/hemilabs/heminetwork" \ + org.label-schema.vcs-ref=$VCS_REF \ + org.label-schema.vendor="Hemi Labs" \ + org.label-schema.version=$VERSION \ + org.label-schema.schema-version="1.0" + +# Copy files +COPY --from=builder /etc/group /etc/group +COPY --from=builder /etc/passwd /etc/passwd +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo +COPY --from=builder /build/bin/popmd /usr/local/bin/popmd + +# Environment variables +ENV POPM_LOG_LEVEL= +ENV POPM_BTC_PRIVKEY= +ENV POPM_BFG_URL= +ENV POPM_BTC_CHAIN_NAME= +ENV POPM_PROMETHEUS_ADDRESS= + +USER popmd:popmd +WORKDIR /etc/popmd/ +ENTRYPOINT ["/usr/local/bin/popmd"] diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml new file mode 100644 index 00000000..825f10f0 --- /dev/null +++ b/e2e/docker-compose.yml @@ -0,0 +1,120 @@ +version: '3' +services: + bitcoind: + image: 'kylemanna/bitcoind' + command: + - 'bitcoind' + - '-regtest=1' + - '-rpcuser=user' + - '-rpcpassword=password' + - '-rpcallowip=0.0.0.0/0' + - '-rpcbind=0.0.0.0:18443' + - '-txindex=1' + ports: + - 18443:18443 + + initialblocks: + image: 'kylemanna/bitcoind' + command: + - 'bitcoin-cli' + - '-regtest=1' + - '-rpcuser=user' + - '-rpcpassword=password' + - '-rpcport=18443' + - '-rpcconnect=bitcoind' + - 'generatetoaddress' + - '300' # need to generate a lot for greater chance to not spend coinbase + - 'mw47rj9rG25J67G6W8bbjRayRQjWN5ZSEG' + depends_on: + - bitcoind + + moreblocks: + image: 'kylemanna/bitcoind' + command: + - 'bitcoin-cli' + - '-regtest=1' + - '-rpcuser=user' + - '-rpcpassword=password' + - '-rpcport=18443' + - '-rpcconnect=bitcoind' + - 'generatetoaddress' + - '1' + - 'mw47rj9rG25J67G6W8bbjRayRQjWN5ZSEG' + deploy: + restart_policy: + condition: any + delay: 5s + depends_on: + - bitcoind + + electrumx: + image: 'lukechilds/electrumx' + environment: + DAEMON_URL: 'http://user:password@bitcoind:18443' + COIN: 'BitcoinSegwit' + NET: 'regtest' + ports: + - 50001:50001 + depends_on: + - bitcoind + + postgres: + build: + dockerfile: ./e2e/postgres.Dockerfile + context: ./.. + environment: + POSTGRES_DB: "bfg" + POSTGRES_HOST_AUTH_METHOD: "trust" + ports: + - 5432:5432 + + bfgd: + build: + dockerfile: ./docker/bfgd/Dockerfile + context: ./.. + environment: + BFG_POSTGRES_URI: postgres://postgres@postgres:5432/bfg?sslmode=disable + BFG_BTC_START_HEIGHT: "100" + BFG_EXBTC_ADDRESS: electrumx:50001 + BFG_LOG_LEVEL: TRACE + BFG_PUBLIC_ADDRESS: ":8383" + BFG_PRIVATE_ADDRESS: ":8080" + ports: + - 8080:8080 + - 8383:8383 + depends_on: + - postgres + + bssd: + build: + dockerfile: ./docker/bssd/Dockerfile + context: ./.. + environment: + BSS_BFG_URL: 'ws://bfgd:8080/v1/ws/private' + BSS_LOG_LEVEL: TRACE + BSS_ADDRESS: ':8081' + ports: + - 8081:8081 + depends_on: + - bfgd + + popmd: + build: + dockerfile: ./docker/popmd/Dockerfile + context: ./.. + environment: + POPM_BTC_PRIVKEY: '72a2c41c84147325ce3c0f37697ef1e670c7169063dda89be9995c3c5219740f' + POPM_BFG_URL: http://bfgd:8383/v1/ws/public + POPM_LOG_LEVEL: TRACE + depends_on: + - bfgd + + mocktimism: + build: + dockerfile: ./e2e/mocktimism/Dockerfile + context: ./.. + environment: + MOCKTIMISM_BSS_URL: http://bssd:8081/v1/ws + depends_on: + - bssd + diff --git a/e2e/e2e_ext_test.go b/e2e/e2e_ext_test.go new file mode 100644 index 00000000..ce694d0b --- /dev/null +++ b/e2e/e2e_ext_test.go @@ -0,0 +1,3688 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package e2e_test + +import ( + "bytes" + "context" + "database/sql" + "encoding/binary" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "math/big" + "math/rand" + "net" + "net/url" + "os" + "path/filepath" + "reflect" + "slices" + "sort" + "strings" + "sync" + "testing" + "time" + + "github.com/btcsuite/btcd/btcutil" + btcchaincfg "github.com/btcsuite/btcd/chaincfg" + btcchainhash "github.com/btcsuite/btcd/chaincfg/chainhash" + btctxscript "github.com/btcsuite/btcd/txscript" + btcwire "github.com/btcsuite/btcd/wire" + dcrsecp256k1 "github.com/decred/dcrd/dcrec/secp256k1/v4" + dcrecdsa "github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/go-test/deep" + "github.com/phayes/freeport" + "nhooyr.io/websocket" + "nhooyr.io/websocket/wsjson" + + "github.com/hemilabs/heminetwork/api" + "github.com/hemilabs/heminetwork/api/auth" + "github.com/hemilabs/heminetwork/api/bfgapi" + "github.com/hemilabs/heminetwork/api/bssapi" + "github.com/hemilabs/heminetwork/api/protocol" + "github.com/hemilabs/heminetwork/database/bfgd" + "github.com/hemilabs/heminetwork/database/bfgd/postgres" + "github.com/hemilabs/heminetwork/hemi" + "github.com/hemilabs/heminetwork/hemi/electrumx" + "github.com/hemilabs/heminetwork/hemi/pop" + "github.com/hemilabs/heminetwork/service/bfg" + "github.com/hemilabs/heminetwork/service/bss" + "github.com/hemilabs/heminetwork/service/popm" +) + +const ( + testDBPrefix = "e2e_ext_test_db_" + mockEncodedBlockHeader = "\"0000c02048cd664586152c3dcf356d010cbb9216fdeb3b1aeae256d59a0700000000000086182c855545356ec11d94972cf31b97ef01ae7c9887f4349ad3f0caf2d3c0b118e77665efdf2819367881fb\"" + mockTxHash = "7fe9c3262f8fe26764b01955b4c996296f7c0c72945af1556038a084fcb37dbb" + mockTxPos = 3 + mockTxheight = 2 + mockElectrumxConnectTimeoutSeconds = 3 * time.Second +) + +var mockMerkleHashes = []string{ + "2ab69ae0bb89b378c7ffa5e3b08389002d08394c6eba21f5655e32e1f60b6261", + "18b2c85e1159d6945eb0b3adab5fef7dc4ab8a04089736c5f3abc5c0e68e7c89", + "a0bead79e8ef526e5e96656e7096055e52c059d92834944e7e37131198aae527", + "01ed7fae1204d5ba058dbb15caf03d253198db053e4bb7bb2f7ec6da7f66a433", + "08cba6d9e9d436a5c76289571795be59b44e61cc0e12d037ec46cae69bae2c09", + "d5c8f9a5257818bf44961b9aedb8602b1fa9000423ee9aede5eeec1d65f197ee", + "d0e8c725b128222b6d284320f5a24c9d49df4270c9c749ee57a104d5e3206b68", +} + +var minerPrivateKeyBytes = []byte{1, 2, 3, 4, 5, 6, 7, 199} // XXX make this a real hardcoded key + +type bssWs struct { + wg sync.WaitGroup + addr string + conn *protocol.WSConn +} + +type bfgWs bssWs + +// Setup some private keys and authenticators +var ( + privateKey *dcrsecp256k1.PrivateKey + authClient *auth.Secp256k1Auth +) + +func init() { + var err error + privateKey, err = dcrsecp256k1.GeneratePrivateKey() + if err != nil { + panic(err) + } + + authClient, err = auth.NewSecp256k1AuthClient(privateKey) + if err != nil { + panic(err) + } +} + +func EnsureCanConnect(t *testing.T, url string, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + t.Logf("connecting to %s", url) + + var err error + + doneCh := make(chan bool) + go func() { + for { + c, _, err := websocket.Dial(ctx, url, nil) + if err != nil { + time.Sleep(1 * time.Second) + continue + } + c.CloseNow() + doneCh <- true + } + }() + + select { + case <-doneCh: + case <-ctx.Done(): + return fmt.Errorf("timed out trying to reach WS server in tests, last error: %s", err) + } + + return nil +} + +func EnsureCanConnectTCP(t *testing.T, addr string, timeout time.Duration) error { + conn, err := net.DialTimeout("tcp", addr, timeout) + if err != nil { + return err + } + + conn.Close() + return nil +} + +func applySQLFiles(ctx context.Context, t *testing.T, sdb *sql.DB, path string) { + t.Helper() + + sqlFiles, err := filepath.Glob(path) + if err != nil { + t.Fatalf("Failed to get schema files: %v", err) + } + sort.Strings(sqlFiles) + + for _, sqlFile := range sqlFiles { + t.Logf("Applying SQL file %v", filepath.Base(sqlFile)) + sql, err := ioutil.ReadFile(sqlFile) + if err != nil { + t.Fatalf("Failed to read SQL file: %v", err) + } + if _, err := sdb.ExecContext(ctx, string(sql)); err != nil { + t.Fatalf("Failed to execute SQL: %v", err) + } + } +} + +func getPgUri(t *testing.T) string { + pgURI := os.Getenv("PGTESTURI") + if pgURI == "" { + t.Skip("PGTESTURI environment variable is not set, skipping...") + } + + return pgURI +} + +func createTestDB(ctx context.Context, t *testing.T) (bfgd.Database, string, *sql.DB, func()) { + t.Helper() + + pgURI := getPgUri(t) + + var ( + cleanup func() + ddb, sdb *sql.DB + needCleanup = true + ) + defer func() { + if !needCleanup { + return + } + if sdb != nil { + sdb.Close() + } + if cleanup != nil { + cleanup() + } + if ddb != nil { + ddb.Close() + } + }() + + ddb, err := postgres.Connect(ctx, pgURI) + if err != nil { + t.Fatalf("Failed to connect to database: %v", err) + } + + dbn := rand.New(rand.NewSource(time.Now().UnixNano())).Intn(999999999) + dbName := fmt.Sprintf("%v_%d", testDBPrefix, dbn) + + t.Logf("Creating test database %v", dbName) + + qCreateDB := fmt.Sprintf("CREATE DATABASE %v", dbName) + if _, err := ddb.ExecContext(ctx, qCreateDB); err != nil { + t.Fatalf("Failed to create test database: %v", err) + } + + cleanup = func() { + t.Logf("Removing test database %v", dbName) + qDropDB := fmt.Sprintf("DROP DATABASE %v WITH (FORCE)", dbName) + if _, err := ddb.ExecContext(ctx, qDropDB); err != nil { + t.Fatalf("Failed to drop test database: %v", err) + } + ddb.Close() + } + + u, err := url.Parse(pgURI) + if err != nil { + t.Fatalf("Failed to parse postgresql URI: %v", err) + } + u.Path = dbName + + sdb, err = postgres.Connect(ctx, u.String()) + if err != nil { + t.Fatalf("Failed to connect to database: %v", err) + } + + // Load schema. + wd, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + applySQLFiles(ctx, t, sdb, filepath.Join(wd, "./../database/bfgd/scripts/*.sql")) + + db, err := postgres.New(ctx, u.String()) + if err != nil { + t.Fatalf("Failed to connect to database: %v", err) + } + + if dbVersion, err := db.Version(ctx); err != nil { + t.Fatalf("Failed to obtain database version: %v", err) + } else { + t.Logf("Database version: %v", dbVersion) + } + + needCleanup = false + + return db, u.String(), sdb, cleanup +} + +func nextPort() int { + port, err := freeport.GetFreePort() + if err != nil && err != context.Canceled { + panic(err) + } + + return port +} + +func createPopm(ctx context.Context, t *testing.T, bfgUrl string, bfgPrivateWsUrl string) (*popm.Miner, error) { + m, err := popm.NewMiner(&popm.Config{ + BFGWSURL: bfgPrivateWsUrl, + BTCChainName: "testnet3", + BTCPrivateKey: "FC4B44FDC798E5D11229B84EC6B21B98EF40B0E4E1D12C6488CB5967F8CE94C6", + }) + if err != nil { + return nil, err + } + + return m, nil +} + +func createBfgServerWithAuth(ctx context.Context, t *testing.T, pgUri string, electrumxAddr string, btcStartHeight uint64, auth bool) (*bfg.Server, string, string, string) { + bfgPrivateListenAddress := fmt.Sprintf(":%d", nextPort()) + bfgPublicListenAddress := fmt.Sprintf(":%d", nextPort()) + + bfgServer, err := bfg.NewServer(&bfg.Config{ + PrivateListenAddress: bfgPrivateListenAddress, + PublicListenAddress: bfgPublicListenAddress, + PgURI: pgUri, + EXBTCAddress: electrumxAddr, + BTCStartHeight: btcStartHeight, + PublicKeyAuth: auth, + }) + if err != nil { + t.Fatal(err) + } + + go func() { + err := bfgServer.Run(ctx) + if err != nil && err != context.Canceled { + panic(err) + } + }() + + bfgWsPrivateUrl := fmt.Sprintf("http://localhost%s%s", bfgPrivateListenAddress, bfgapi.RouteWebsocketPrivate) + bfgWsPublicUrl := fmt.Sprintf("http://localhost%s%s", bfgPublicListenAddress, bfgapi.RouteWebsocketPublic) + + if err := EnsureCanConnect(t, bfgWsPrivateUrl, 5*time.Second); err != nil { + t.Fatalf("could not connect to %s: %s", bfgWsPrivateUrl, err.Error()) + } + + if err := EnsureCanConnect(t, bfgWsPublicUrl, 5*time.Second); err != nil { + t.Fatalf("could not connect to %s: %s", bfgWsPublicUrl, err.Error()) + } + + return bfgServer, bfgPrivateListenAddress, bfgWsPrivateUrl, bfgWsPublicUrl +} + +func createBfgServer(ctx context.Context, t *testing.T, pgUri string, electrumxAddr string, btcStartHeight uint64) (*bfg.Server, string, string, string) { + return createBfgServerWithAuth(ctx, t, pgUri, electrumxAddr, btcStartHeight, false) +} + +func createBssServer(ctx context.Context, t *testing.T, bfgWsurl string) (*bss.Server, string, string) { + bssListenAddress := fmt.Sprintf(":%d", nextPort()) + + bssServer, err := bss.NewServer(&bss.Config{ + BFGURL: bfgWsurl, + ListenAddress: bssListenAddress, + }) + if err != nil { + t.Fatal(err) + } + + go func() { + err := bssServer.Run(ctx) + if err != nil && err != context.Canceled { + panic(err) + } + }() + + bssWsurl := fmt.Sprintf("http://localhost%s%s", bssListenAddress, bssapi.RouteWebsocket) + err = EnsureCanConnect(t, bssWsurl, 5*time.Second) + if err != nil { + t.Fatalf("could not connect to %s: %s", bssWsurl, err.Error()) + } + + return bssServer, bssListenAddress, bssWsurl +} + +func reverseAndEncodeEncodedHash(encodedHash string) string { + rev, err := hex.DecodeString(encodedHash) + if err != nil { + panic(err) + } + + slices.Reverse(rev) + + return hex.EncodeToString(rev) +} + +func createMockElectrumxServer(ctx context.Context, t *testing.T, l2Keystone *hemi.L2Keystone, btx []byte) (string, func()) { + addr := fmt.Sprintf("localhost:%d", nextPort()) + + listener, err := net.Listen("tcp", addr) + if err != nil { + t.Fatal(err) + } + cleanup := func() { + listener.Close() + } + + go func() { + for { + select { + case <-ctx.Done(): + return + default: + } + conn, err := listener.Accept() + + // annoyingly, we have to compare the error string here + if err != nil && strings.Contains(err.Error(), "use of closed network connection") { + time.Sleep(100 * time.Millisecond) + continue + } + + if err != nil { + panic(err) + } + + buf := make([]byte, 1000) + n, err := conn.Read(buf) + if err != nil { + t.Logf( + "error occurred reading from conn, will listen again: %s", + err.Error(), + ) + conn.Close() + continue + } + + req := electrumx.JSONRPCRequest{} + err = json.Unmarshal(buf[:n], &req) + if err != nil { + panic(err) + } + + res := electrumx.JSONRPCResponse{} + if req.Method == "blockchain.transaction.broadcast" { + res.ID = req.ID + res.Error = nil + res.Result = json.RawMessage([]byte(fmt.Sprintf("\"%s\"", mockTxHash))) + } + + if req.Method == "blockchain.headers.subscribe" { + res.ID = req.ID + res.Error = nil + headerNotification := electrumx.HeaderNotification{ + Height: mockTxheight, + BinaryHeader: "aaaa", + } + + b, err := json.Marshal(&headerNotification) + if err != nil { + panic(err) + } + + res.Result = b + } + + if req.Method == "blockchain.block.header" { + res.ID = req.ID + res.Error = nil + res.Result = json.RawMessage([]byte(mockEncodedBlockHeader)) + } + + if req.Method == "blockchain.transaction.id_from_pos" { + res.ID = req.ID + res.Error = nil + params := struct { + Height uint64 `json:"height"` + TXPos uint64 `json:"tx_pos"` + Merkle bool `json:"merkle"` + }{} + + err := json.Unmarshal(req.Params, ¶ms) + if err != nil { + panic(err) + } + + result := struct { + TXHash string `json:"tx_hash"` + Merkle []string `json:"merkle"` + }{} + + t.Logf("checking height %d, pos %d", params.Height, params.TXPos) + + if params.TXPos == mockTxPos && params.Height == mockTxheight { + result.TXHash = reverseAndEncodeEncodedHash(mockTxHash) + result.Merkle = mockMerkleHashes + } + + // pretend that there are no transactions past mockTxHeight + // and mockTxPos + if params.Height >= mockTxheight && params.TXPos > mockTxPos { + res.Error = electrumx.NewJSONRPCError(1, "no tx at pos") + } + + b, err := json.Marshal(&result) + if err != nil { + panic(err) + } + + res.Result = b + } + + if req.Method == "blockchain.transaction.get" { + res.ID = req.ID + res.Error = nil + + params := struct { + TXHash string `json:"tx_hash"` + Verbose bool `json:"verbose"` + }{} + + err := json.Unmarshal(req.Params, ¶ms) + if err != nil { + panic(err) + } + + if params.TXHash == reverseAndEncodeEncodedHash(mockTxHash) { + j, err := json.Marshal(hex.EncodeToString(btx)) + if err != nil { + panic(err) + } + res.Result = j + } + } + + if req.Method == "blockchain.scripthash.get_balance" { + res.ID = req.ID + res.Error = nil + j, err := json.Marshal(electrumx.Balance{ + Confirmed: 1, + Unconfirmed: 2, + }) + if err != nil { + panic(err) + } + + res.Result = j + } + + if req.Method == "blockchain.headers.subscribe" { + res.ID = req.ID + res.Error = nil + j, err := json.Marshal(electrumx.HeaderNotification{ + Height: 10, + }) + if err != nil { + panic(err) + } + res.Result = j + } + + if req.Method == "blockchain.scripthash.listunspent" { + res.ID = req.ID + res.Error = nil + hash := []byte{ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 1, 2, 3, 4, 5, 6, + 7, 8, 9, 10, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 1, 2, + } + _j := []struct { + Hash string `json:"tx_hash"` + Height uint64 `json:"height"` + Index uint64 `json:"tx_pos"` + Value uint64 `json:"value"` + }{{ + Height: 99, + Hash: hex.EncodeToString(hash), + Index: 9999, + Value: 999999, + }} + j, err := json.Marshal(_j) + if err != nil { + panic(err) + } + + res.Result = j + } + + b, err := json.Marshal(res) + if err != nil { + panic(err) + } + + b = append(b, '\n') + _, err = io.Copy(conn, bytes.NewReader(b)) + if err != nil { + panic(err) + } + + conn.Close() + } + }() + + return addr, cleanup +} + +func defaultTestContext() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), 30*time.Second) +} + +// assertPing is a short helper method to assert reading a ping after connecting +func assertPing(ctx context.Context, t *testing.T, c *websocket.Conn, cmd protocol.Command) { + var v protocol.Message + err := wsjson.Read(ctx, c, &v) + if err != nil { + t.Fatal(err) + } + + if v.Header.Command != cmd { + t.Fatalf("unexpected command: %s", v.Header.Command) + } +} + +// fillOutBytes will take a string and return a slice of bytes +// with values from the string suffixed until a size with bytes '_' +func fillOutBytes(prefix string, size int) []byte { + result := []byte(prefix) + for len(result) < size { + result = append(result, '_') + } + + return result +} + +func bfgdL2KeystoneToHemiL2Keystone(l2KeystoneSavedDB *bfgd.L2Keystone) *hemi.L2Keystone { + return &hemi.L2Keystone{ + Version: uint8(l2KeystoneSavedDB.Version), + L1BlockNumber: l2KeystoneSavedDB.L1BlockNumber, + L2BlockNumber: l2KeystoneSavedDB.L2BlockNumber, + ParentEPHash: api.ByteSlice(l2KeystoneSavedDB.ParentEPHash), + PrevKeystoneEPHash: api.ByteSlice(l2KeystoneSavedDB.PrevKeystoneEPHash), + StateRoot: api.ByteSlice(l2KeystoneSavedDB.StateRoot), + EPHash: api.ByteSlice(l2KeystoneSavedDB.EPHash), + } +} + +func createBtcTx(t *testing.T, btcHeight uint64, l2Keystone *hemi.L2Keystone, minerPrivateKeyBytes []byte) []byte { + btx := &btcwire.MsgTx{ + Version: 2, + LockTime: uint32(btcHeight), + } + + popTx := pop.TransactionL2{ + L2Keystone: hemi.L2KeystoneAbbreviate(*l2Keystone), + } + + popTxOpReturn, err := popTx.EncodeToOpReturn() + if err != nil { + t.Fatal(err) + } + + privateKey := dcrsecp256k1.PrivKeyFromBytes(minerPrivateKeyBytes) + publicKey := privateKey.PubKey() + pubKeyBytes := publicKey.SerializeCompressed() + btcAddress, err := btcutil.NewAddressPubKey(pubKeyBytes, &btcchaincfg.TestNet3Params) + if err != nil { + t.Fatal(err) + } + + payToScript, err := btctxscript.PayToAddrScript(btcAddress.AddressPubKeyHash()) + if err != nil { + t.Fatal(err) + } + + if len(payToScript) != 25 { + t.Fatalf("incorrect length for pay to public key script (%d != 25)", len(payToScript)) + } + + outPoint := btcwire.OutPoint{Hash: btcchainhash.Hash(fillOutBytes("hash", 32)), Index: 0} + btx.TxIn = []*btcwire.TxIn{btcwire.NewTxIn(&outPoint, payToScript, nil)} + + changeAmount := int64(100) + btx.TxOut = []*btcwire.TxOut{btcwire.NewTxOut(changeAmount, payToScript)} + + btx.TxOut = append(btx.TxOut, btcwire.NewTxOut(0, popTxOpReturn)) + + sig := dcrecdsa.Sign(privateKey, []byte{}) + sigBytes := append(sig.Serialize(), byte(btctxscript.SigHashAll)) + sigScript, err := btctxscript.NewScriptBuilder().AddData(sigBytes).AddData(pubKeyBytes).Script() + if err != nil { + t.Fatal(err) + } + btx.TxIn[0].SignatureScript = sigScript + + var buf bytes.Buffer + if err := btx.Serialize(&buf); err != nil { + t.Fatal(err) + } + + return buf.Bytes() +} + +// TestNewL2Keystone sends an L2Keystone, via websocket, to BSS which proxies +// it to BFG. This test then ensures that that L2Keystone was saved in the db +// 1. Create a new L2Keystone +// 2. Send aforementioned L2Keystone to BSS via websocket +// 3. Query database to ensure that the L2Keystone was saved +func TestNewL2Keystone(t *testing.T) { + db, pgUri, sdb, cleanup := createTestDB(context.Background(), t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + ctx, cancel := defaultTestContext() + defer cancel() + + _, _, bfgWsurl, _ := createBfgServer(ctx, t, pgUri, "", 1) + + _, _, bssWsurl := createBssServer(ctx, t, bfgWsurl) + + c, _, err := websocket.Dial(ctx, bssWsurl, nil) + if err != nil { + t.Fatal(err) + } + defer c.CloseNow() + + assertPing(ctx, t, c, bssapi.CmdPingRequest) + + // 1 + l2Keystone := hemi.L2Keystone{ + Version: 1, + L1BlockNumber: 11, + L2BlockNumber: 22, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + } + + l2KeystoneRequest := bssapi.L2KeystoneRequest{ + L2Keystone: l2Keystone, + } + + bws := &bssWs{ + conn: protocol.NewWSConn(c), + } + + // 2 + err = bssapi.Write(ctx, bws.conn, "someid", l2KeystoneRequest) + if err != nil { + t.Fatal(err) + } + + var v protocol.Message + err = wsjson.Read(ctx, c, &v) + if err != nil { + t.Fatal(err) + } + + if v.Header.Command != bssapi.CmdL2KeystoneResponse { + t.Fatalf("received unexpected command: %s", v.Header.Command) + } + + l2KeystoneAbrevHash := hemi.L2KeystoneAbbreviate(l2KeystoneRequest.L2Keystone).Hash() + + // 3 + l2KeystoneSavedDB, err := db.L2KeystoneByAbrevHash(ctx, [32]byte(l2KeystoneAbrevHash)) + if err != nil { + t.Fatal(err) + } + + l2KeystoneSaved := bfgdL2KeystoneToHemiL2Keystone(l2KeystoneSavedDB) + + diff := deep.Equal(l2KeystoneSaved, &l2Keystone) + if len(diff) != 0 { + t.Fatalf("unexpected diff %s", diff) + } +} + +// TestL2Keystone tests getting the latest L2Keystones from the db +// and ensuring the are ordered by L2BlockNumber +// 1. Create multiple L2Keystones with different L2BlockNumbers +// 2. Insert aforementioned L2Keystones into database +// 3. Query BFG via http json rpc for latest keystones +// 4. Assert that the saved keystones are returned ordered by L2BlockNumber desc +func TestL2Keystone(t *testing.T) { + db, pgUri, sdb, cleanup := createTestDB(context.Background(), t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + ctx, cancel := defaultTestContext() + defer cancel() + + _, _, _, bfgPublicWsUrl := createBfgServer(ctx, t, pgUri, "", 1) + + keystoneOneHash := fillOutBytes("somehashone", 32) + keystoneTwoHash := fillOutBytes("somehashtwo", 32) + + // 1 + keystoneOne := bfgd.L2Keystone{ + Hash: keystoneOneHash, + Version: 1, + L1BlockNumber: 11, + L2BlockNumber: 22, + ParentEPHash: fillOutBytes("parentephashone", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephashone", 32), + StateRoot: fillOutBytes("staterootone", 32), + EPHash: fillOutBytes("ephashone", 32), + } + + keystoneTwo := bfgd.L2Keystone{ + Hash: keystoneTwoHash, + Version: 1, + L1BlockNumber: 33, + L2BlockNumber: 44, + ParentEPHash: fillOutBytes("parentephashtwo", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephashtwo", 32), + StateRoot: fillOutBytes("stateroottwo", 32), + EPHash: fillOutBytes("ephashtwo", 32), + } + + // 2 + err := db.L2KeystonesInsert(ctx, []bfgd.L2Keystone{ + keystoneOne, + keystoneTwo, + }) + if err != nil { + t.Fatal(err) + } + + l2KeystonesRequest := bfgapi.L2KeystonesRequest{ + NumL2Keystones: 5, + } + + c, _, err := websocket.Dial(ctx, bfgPublicWsUrl, nil) + if err != nil { + t.Fatal(err) + } + defer c.CloseNow() + + protocolConn := protocol.NewWSConn(c) + if err := authClient.HandshakeClient(ctx, protocolConn); err != nil { + t.Fatal(err) + } + assertPing(ctx, t, c, bfgapi.CmdPingRequest) + + bws := &bfgWs{ + conn: protocol.NewWSConn(c), + } + + // 2 + err = bfgapi.Write(ctx, bws.conn, "someid", l2KeystonesRequest) + if err != nil { + t.Fatal(err) + } + + command, _, response, err := bfgapi.Read(ctx, bws.conn) + if err != nil { + t.Fatal(err) + } + + if command != bfgapi.CmdL2KeystonesResponse { + t.Fatalf("unexpected command %s", command) + } + + l2KeystonesResponse := response.(*bfgapi.L2KeystonesResponse) + + // 4 + diff := deep.Equal(l2KeystonesResponse, &bfgapi.L2KeystonesResponse{ + L2Keystones: []hemi.L2Keystone{ + *bfgdL2KeystoneToHemiL2Keystone(&keystoneTwo), + *bfgdL2KeystoneToHemiL2Keystone(&keystoneOne), + }, + }) + + if len(diff) > 0 { + t.Fatalf("unexpected diff %s", diff) + } +} + +func TestPublicPing(t *testing.T) { + db, pgUri, sdb, cleanup := createTestDB(context.Background(), t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + ctx, cancel := defaultTestContext() + defer cancel() + + _, _, _, bfgPublicWsUrl := createBfgServer(ctx, t, pgUri, "", 1) + + c, _, err := websocket.Dial(ctx, bfgPublicWsUrl, nil) + if err != nil { + t.Fatal(err) + } + defer c.CloseNow() + + protocolConn := protocol.NewWSConn(c) + if err := authClient.HandshakeClient(ctx, protocolConn); err != nil { + t.Fatal(err) + } + assertPing(ctx, t, c, bfgapi.CmdPingRequest) +} + +func TestBitcoinBalance(t *testing.T) { + db, pgUri, sdb, cleanup := createTestDB(context.Background(), t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + ctx, cancel := defaultTestContext() + defer cancel() + + l2Keystone := hemi.L2Keystone{ + Version: 1, + L1BlockNumber: 5, + L2BlockNumber: 44, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + } + + btx := createBtcTx(t, 199, &l2Keystone, minerPrivateKeyBytes) + + electrumxAddr, cleanupE := createMockElectrumxServer(ctx, t, nil, btx) + defer cleanupE() + err := EnsureCanConnectTCP(t, electrumxAddr, mockElectrumxConnectTimeoutSeconds) + if err != nil { + t.Fatal(err) + } + + _, _, _, bfgPublicWsUrl := createBfgServer(ctx, t, pgUri, electrumxAddr, 1) + + c, _, err := websocket.Dial(ctx, bfgPublicWsUrl, nil) + if err != nil { + t.Fatal(err) + } + defer c.CloseNow() + + protocolConn := protocol.NewWSConn(c) + if err := authClient.HandshakeClient(ctx, protocolConn); err != nil { + t.Fatal(err) + } + assertPing(ctx, t, c, bfgapi.CmdPingRequest) + + bws := &bfgWs{ + conn: protocol.NewWSConn(c), + } + + sh := make([]byte, 32) + _, err = rand.Read(sh) + if err != nil { + t.Fatal(err) + } + + if err := bfgapi.Write(ctx, bws.conn, "someid", &bfgapi.BitcoinBalanceRequest{ + ScriptHash: sh, + }); err != nil { + t.Fatal(err) + } + + command, _, v, err := bfgapi.Read(ctx, bws.conn) + if err != nil { + t.Fatal(err) + } + bitcoinBalanceResponse := v.(*bfgapi.BitcoinBalanceResponse) + + if command != bfgapi.CmdBitcoinBalanceResponse { + t.Fatalf("unexpected command: %s", command) + } + + if diff := deep.Equal(bitcoinBalanceResponse, &bfgapi.BitcoinBalanceResponse{ + Unconfirmed: 2, + Confirmed: 1, + }); len(diff) > 0 { + t.Fatalf("unexpected diff %s", diff) + } +} + +func TestBFGPublicErrorCases(t *testing.T) { + l2Keystone := hemi.L2Keystone{ + Version: 1, + L1BlockNumber: 5, + L2BlockNumber: 44, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + } + + btx := createBtcTx(t, 199, &l2Keystone, minerPrivateKeyBytes) + + type testTableItem struct { + name string + expectedError string + requests any + electrumx bool + } + + testTable := []testTableItem{ + { + name: "bitcoin balance error", + expectedError: "internal error", + requests: []bfgapi.BitcoinBalanceRequest{}, + electrumx: false, + }, + { + name: "bitcoin broadcast deserialize error", + expectedError: "failed to deserialized tx: unexpected EOF", + requests: []bfgapi.BitcoinBroadcastRequest{ + { + Transaction: []byte("invalid..."), + }, + }, + electrumx: false, + }, + { + name: "bitcoin broadcast electrumx error", + expectedError: "internal error", + requests: []bfgapi.BitcoinBroadcastRequest{ + { + Transaction: btx, + }, + }, + electrumx: false, + }, + { + name: "bitcoin broadcast database error", + expectedError: "pop_basis already exists", + requests: []bfgapi.BitcoinBroadcastRequest{ + { + Transaction: btx, + }, + { + Transaction: btx, + }, + }, + electrumx: true, + }, + { + name: "bitcoin info electrumx error", + expectedError: "internal error", + requests: []bfgapi.BitcoinInfoRequest{ + {}, + }, + electrumx: false, + }, + { + name: "bitcoin utxos electrumx error", + expectedError: "internal error", + requests: []bfgapi.BitcoinUTXOsRequest{ + {}, + }, + electrumx: false, + }, + } + + for _, tti := range testTable { + t.Run(tti.name, func(t *testing.T) { + db, pgUri, sdb, cleanup := createTestDB(context.Background(), t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + ctx, cancel := defaultTestContext() + defer cancel() + + electrumxAddr := "" + var cleanupE func() + + if tti.electrumx { + l2Keystone := hemi.L2Keystone{ + Version: 1, + L1BlockNumber: 5, + L2BlockNumber: 44, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + } + + btx := createBtcTx(t, 199, &l2Keystone, minerPrivateKeyBytes) + + electrumxAddr, cleanupE = createMockElectrumxServer(ctx, t, nil, btx) + defer cleanupE() + err := EnsureCanConnectTCP(t, electrumxAddr, mockElectrumxConnectTimeoutSeconds) + if err != nil { + t.Fatal(err) + } + } + + _, _, _, bfgPublicWsUrl := createBfgServer(ctx, t, pgUri, electrumxAddr, 1) + + c, _, err := websocket.Dial(ctx, bfgPublicWsUrl, nil) + if err != nil { + t.Fatal(err) + } + defer c.CloseNow() + + protocolConn := protocol.NewWSConn(c) + if err := authClient.HandshakeClient(ctx, protocolConn); err != nil { + t.Fatal(err) + } + + assertPing(ctx, t, c, bfgapi.CmdPingRequest) + + bws := &bfgWs{ + conn: protocol.NewWSConn(c), + } + + requests := reflect.ValueOf(tti.requests) + for i := 0; i < requests.Len(); i++ { + req := requests.Index(i).Interface() + if err := bfgapi.Write(ctx, bws.conn, "someid", req); err != nil { + t.Fatal(err) + } + + _, _, response, err := bfgapi.Read(ctx, bws.conn) + if err != nil { + t.Fatal(err) + } + + // we only care about testing the final response, this allows + // us to test multiple and duplicate requests + if i != requests.Len()-1 { + continue + } + + switch v := response.(type) { + case *bfgapi.BitcoinBalanceResponse: + if v.Error.Message != tti.expectedError { + t.Fatalf("%s != %s", v.Error.Message, tti.expectedError) + } + case *bfgapi.BitcoinBroadcastResponse: + if v.Error.Message != tti.expectedError { + t.Fatalf("%s != %s", v.Error.Message, tti.expectedError) + } + case *bfgapi.BitcoinInfoResponse: + if v.Error.Message != tti.expectedError { + t.Fatalf("%s != %s", v.Error.Message, tti.expectedError) + } + case *bfgapi.BitcoinUTXOsResponse: + if v.Error.Message != tti.expectedError { + t.Fatalf("%s != %s", v.Error.Message, tti.expectedError) + } + default: + t.Fatalf("cannot determine type %T", v) + } + } + }) + } +} + +func TestBFGPrivateErrorCases(t *testing.T) { + type testTableItem struct { + name string + expectedError string + requests any + } + + testTable := []testTableItem{ + { + name: "public key create duplicate", + expectedError: "public key already exists", + requests: []bfgapi.AccessPublicKeyCreateRequest{ + { + PublicKey: hex.EncodeToString(privateKey.PubKey().SerializeCompressed()), + }, + { + PublicKey: hex.EncodeToString(privateKey.PubKey().SerializeCompressed()), + }, + }, + }, + { + name: "public key does not exist when deleting", + expectedError: "public key not found", + requests: []bfgapi.AccessPublicKeyDeleteRequest{ + { + PublicKey: hex.EncodeToString(privateKey.PubKey().SerializeCompressed()), + }, + }, + }, + { + name: "public key is invalid", + expectedError: "encoding/hex: invalid byte: U+006C 'l'", + requests: []bfgapi.AccessPublicKeyCreateRequest{ + { + PublicKey: "blahblahblah", + }, + }, + }, + } + + for _, tti := range testTable { + t.Run(tti.name, func(t *testing.T) { + db, pgUri, sdb, cleanup := createTestDB(context.Background(), t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + ctx, cancel := defaultTestContext() + defer cancel() + + _, _, bfgPrivateWsUrl, _ := createBfgServer(ctx, t, pgUri, "", 1) + + c, _, err := websocket.Dial(ctx, bfgPrivateWsUrl, nil) + if err != nil { + t.Fatal(err) + } + defer c.CloseNow() + + assertPing(ctx, t, c, bfgapi.CmdPingRequest) + + bws := &bfgWs{ + conn: protocol.NewWSConn(c), + } + + requests := reflect.ValueOf(tti.requests) + for i := 0; i < requests.Len(); i++ { + req := requests.Index(i).Interface() + if err := bfgapi.Write(ctx, bws.conn, "someid", req); err != nil { + t.Fatal(err) + } + + _, _, response, err := bfgapi.Read(ctx, bws.conn) + if err != nil { + t.Fatal(err) + } + + // we only care about testing the final response, this allows + // us to test multiple and duplicate requests + if i != requests.Len()-1 { + continue + } + + switch v := response.(type) { + case *bfgapi.AccessPublicKeyCreateResponse: + if v.Error.Message != tti.expectedError { + t.Fatalf("%s != %s", v.Error.Message, tti.expectedError) + } + case *bfgapi.AccessPublicKeyDeleteResponse: + if v.Error.Message != tti.expectedError { + t.Fatalf("%s != %s", v.Error.Message, tti.expectedError) + } + default: + t.Fatalf("cannot determine type %T", v) + } + } + }) + } +} + +func TestBitcoinInfo(t *testing.T) { + db, pgUri, sdb, cleanup := createTestDB(context.Background(), t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + ctx, cancel := defaultTestContext() + defer cancel() + + l2Keystone := hemi.L2Keystone{ + Version: 1, + L1BlockNumber: 5, + L2BlockNumber: 44, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + } + + btx := createBtcTx(t, 199, &l2Keystone, minerPrivateKeyBytes) + + electrumxAddr, cleanupE := createMockElectrumxServer(ctx, t, nil, btx) + defer cleanupE() + err := EnsureCanConnectTCP(t, electrumxAddr, mockElectrumxConnectTimeoutSeconds) + if err != nil { + t.Fatal(err) + } + + _, _, _, bfgPublicWsUrl := createBfgServer(ctx, t, pgUri, electrumxAddr, 1) + + c, _, err := websocket.Dial(ctx, bfgPublicWsUrl, nil) + if err != nil { + t.Fatal(err) + } + defer c.CloseNow() + + protocolConn := protocol.NewWSConn(c) + if err := authClient.HandshakeClient(ctx, protocolConn); err != nil { + t.Fatal(err) + } + assertPing(ctx, t, c, bfgapi.CmdPingRequest) + + bws := &bfgWs{ + conn: protocol.NewWSConn(c), + } + + sh := make([]byte, 32) + _, err = rand.Read(sh) + if err != nil { + t.Fatal(err) + } + + if err := bfgapi.Write( + ctx, bws.conn, "someid", &bfgapi.BitcoinInfoRequest{}, + ); err != nil { + t.Fatal(err) + } + + command, _, v, err := bfgapi.Read(ctx, bws.conn) + if err != nil { + t.Fatal(err) + } + bitcoinInfoResponse := v.(*bfgapi.BitcoinInfoResponse) + + if command != bfgapi.CmdBitcoinInfoResponse { + t.Fatalf("unexpected command: %s", command) + } + + if diff := deep.Equal(bitcoinInfoResponse, &bfgapi.BitcoinInfoResponse{ + Height: 10, + }); len(diff) > 0 { + t.Fatalf("unexpected diff %s", diff) + } +} + +func TestBitcoinUTXOs(t *testing.T) { + db, pgUri, sdb, cleanup := createTestDB(context.Background(), t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + ctx, cancel := defaultTestContext() + defer cancel() + + l2Keystone := hemi.L2Keystone{ + Version: 1, + L1BlockNumber: 5, + L2BlockNumber: 44, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + } + + btx := createBtcTx(t, 199, &l2Keystone, minerPrivateKeyBytes) + + electrumxAddr, cleanupE := createMockElectrumxServer(ctx, t, nil, btx) + defer cleanupE() + err := EnsureCanConnectTCP(t, electrumxAddr, mockElectrumxConnectTimeoutSeconds) + if err != nil { + t.Fatal(err) + } + + _, _, _, bfgPublicWsUrl := createBfgServer(ctx, t, pgUri, electrumxAddr, 1) + + c, _, err := websocket.Dial(ctx, bfgPublicWsUrl, nil) + if err != nil { + t.Fatal(err) + } + defer c.CloseNow() + + protocolConn := protocol.NewWSConn(c) + if err := authClient.HandshakeClient(ctx, protocolConn); err != nil { + t.Fatal(err) + } + assertPing(ctx, t, c, bfgapi.CmdPingRequest) + + bws := &bfgWs{ + conn: protocol.NewWSConn(c), + } + + sh := make([]byte, 32) + _, err = rand.Read(sh) + if err != nil { + t.Fatal(err) + } + + if err := bfgapi.Write(ctx, bws.conn, "someid", &bfgapi.BitcoinUTXOsRequest{ + ScriptHash: sh, + }); err != nil { + t.Fatal(err) + } + + command, _, v, err := bfgapi.Read(ctx, bws.conn) + if err != nil { + t.Fatal(err) + } + + if command != bfgapi.CmdBitcoinUTXOsResponse { + t.Fatalf("unexpected command: %s", command) + } + bitcoinUTXOsResponse := v.(*bfgapi.BitcoinUTXOsResponse) + + if diff := deep.Equal(bitcoinUTXOsResponse, &bfgapi.BitcoinUTXOsResponse{ + UTXOs: []*bfgapi.BitcoinUTXO{ + { + Index: 9999, + Value: 999999, + Hash: []byte{ + 2, 1, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 10, 9, 8, + 7, 6, 5, 4, 3, 2, 1, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, + }, + }, + }, + }); len(diff) > 0 { + t.Fatalf("unexpected diff %s", diff) + } +} + +// TestBitcoinBroadcast calls the BitcoinBroadcast RPC on BFG, it then +// ensures the correct fields were saved in pop_basis +// 1. create a bitcoin tx +// 2. call BitcoinBroadcast RPC on BFG +// 3. ensure that a pop_basis was inserted with the expected values +func TestBitcoinBroadcast(t *testing.T) { + db, pgUri, sdb, cleanup := createTestDB(context.Background(), t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + ctx, cancel := defaultTestContext() + defer cancel() + + l2Keystone := hemi.L2Keystone{ + Version: 1, + L1BlockNumber: 5, + L2BlockNumber: 44, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + } + + // 1 + btx := createBtcTx(t, 800, &l2Keystone, minerPrivateKeyBytes) + + electrumxAddr, cleanupE := createMockElectrumxServer(ctx, t, nil, btx) + defer cleanupE() + err := EnsureCanConnectTCP(t, electrumxAddr, mockElectrumxConnectTimeoutSeconds) + if err != nil { + t.Fatal(err) + } + + _, _, _, bfgPublicWsUrl := createBfgServer(ctx, t, pgUri, electrumxAddr, 1) + + minerPrivateKeyBytes := []byte{1, 2, 3, 4, 5, 6, 7, 199} + + bitcoinBroadcastRequest := bfgapi.BitcoinBroadcastRequest{ + Transaction: btx, + } + + // 2 + c, _, err := websocket.Dial(ctx, bfgPublicWsUrl, nil) + if err != nil { + t.Fatal(err) + } + + defer c.CloseNow() + + privateKey := dcrsecp256k1.PrivKeyFromBytes(minerPrivateKeyBytes) + + authClient, err := auth.NewSecp256k1AuthClient(privateKey) + if err != nil { + t.Fatal(err) + } + + protocolConn := protocol.NewWSConn(c) + if err := authClient.HandshakeClient(ctx, protocolConn); err != nil { + t.Fatal(err) + } + assertPing(ctx, t, c, bfgapi.CmdPingRequest) + + bws := &bfgWs{ + conn: protocol.NewWSConn(c), + } + + if err := bfgapi.Write( + ctx, bws.conn, "someid", bitcoinBroadcastRequest, + ); err != nil { + t.Fatal(err) + } + + command, _, _, err := bfgapi.Read(ctx, bws.conn) + if err != nil { + t.Fatal(err) + } + + if command != bfgapi.CmdBitcoinBroadcastResponse { + t.Fatalf("received wrong command %s", command) + } + + publicKey := privateKey.PubKey() + publicKeyUncompressed := publicKey.SerializeUncompressed() + + // 3 + popBases, err := db.PopBasisByL2KeystoneAbrevHash(ctx, [32]byte(hemi.L2KeystoneAbbreviate(l2Keystone).Hash()), false) + if err != nil { + t.Fatal(err) + } + + btcTxId, err := btcchainhash.NewHashFromStr(mockTxHash) + if err != nil { + t.Fatal(err) + } + + diff := deep.Equal(popBases, []bfgd.PopBasis{ + { + L2KeystoneAbrevHash: hemi.L2KeystoneAbbreviate(l2Keystone).Hash(), + PopMinerPublicKey: publicKeyUncompressed, + BtcRawTx: btx, + BtcTxId: btcTxId[:], + BtcMerklePath: nil, + BtcHeaderHash: nil, + PopTxId: nil, + BtcTxIndex: nil, + }, + }) + + if len(diff) > 0 { + t.Fatalf("unexpected diff: %s", diff) + } +} + +// TestBitcoinBroadcastDuplicate calls BitcoinBroadcast twice with the same +// btc and ensures that only 1 pop_basis was inserted and the proper error code +// is returned upon duplicate attempt +// 1 create btc tx +// 2 call BitcoinBroadcast RPC with aforementioned btx +// 3 ensure that the correct pop_basis was inserted +// 4 repeat BitcoinBroadcast RPC call +// 5 assert error received +func TestBitcoinBroadcastDuplicate(t *testing.T) { + db, pgUri, sdb, cleanup := createTestDB(context.Background(), t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + ctx, cancel := defaultTestContext() + defer cancel() + + l2Keystone := hemi.L2Keystone{ + Version: 1, + L1BlockNumber: 5, + L2BlockNumber: 44, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + } + + // 1 + btx := createBtcTx(t, 800, &l2Keystone, minerPrivateKeyBytes) + + electrumxAddr, cleanupE := createMockElectrumxServer(ctx, t, nil, btx) + defer cleanupE() + err := EnsureCanConnectTCP(t, electrumxAddr, mockElectrumxConnectTimeoutSeconds) + if err != nil { + t.Fatal(err) + } + + _, _, _, bfgPublicWsUrl := createBfgServer(ctx, t, pgUri, electrumxAddr, 1) + + minerPrivateKeyBytes := []byte{1, 2, 3, 4, 5, 6, 7, 199} + + // 2 + bitcoinBroadcastRequest := bfgapi.BitcoinBroadcastRequest{ + Transaction: btx, + } + + c, _, err := websocket.Dial(ctx, bfgPublicWsUrl, nil) + if err != nil { + t.Fatal(err) + } + defer c.CloseNow() + + privateKey := dcrsecp256k1.PrivKeyFromBytes(minerPrivateKeyBytes) + + authClient, err := auth.NewSecp256k1AuthClient(privateKey) + if err != nil { + t.Fatal(err) + } + + protocolConn := protocol.NewWSConn(c) + if err := authClient.HandshakeClient(ctx, protocolConn); err != nil { + t.Fatal(err) + } + assertPing(ctx, t, c, bfgapi.CmdPingRequest) + + bws := &bfgWs{ + conn: protocol.NewWSConn(c), + } + + // 2 + if err := bfgapi.Write( + ctx, bws.conn, "someid", bitcoinBroadcastRequest, + ); err != nil { + t.Fatal(err) + } + + command, _, _, err := bfgapi.Read(ctx, bws.conn) + if err != nil { + t.Fatal(err) + } + + if command != bfgapi.CmdBitcoinBroadcastResponse { + t.Fatalf("unexpected command %s", command) + } + + publicKey := privateKey.PubKey() + publicKeyUncompressed := publicKey.SerializeUncompressed() + + // 3 + popBases, err := db.PopBasisByL2KeystoneAbrevHash(ctx, [32]byte(hemi.L2KeystoneAbbreviate(l2Keystone).Hash()), false) + if err != nil { + t.Fatal(err) + } + + btcTxId, err := btcchainhash.NewHashFromStr(mockTxHash) + if err != nil { + t.Fatal(err) + } + + diff := deep.Equal(popBases, []bfgd.PopBasis{ + { + L2KeystoneAbrevHash: hemi.L2KeystoneAbbreviate(l2Keystone).Hash(), + PopMinerPublicKey: publicKeyUncompressed, + BtcRawTx: btx, + BtcTxId: btcTxId[:], + BtcMerklePath: nil, + BtcHeaderHash: nil, + PopTxId: nil, + BtcTxIndex: nil, + }, + }) + + if len(diff) > 0 { + t.Fatalf("unexpected diff: %s", diff) + } + + // 4 + c, _, err = websocket.Dial(ctx, bfgPublicWsUrl, nil) + if err != nil { + t.Fatal(err) + } + defer c.CloseNow() + + protocolConn = protocol.NewWSConn(c) + + if err := authClient.HandshakeClient(ctx, protocolConn); err != nil { + t.Fatal(err) + } + assertPing(ctx, t, c, bfgapi.CmdPingRequest) + + bws = &bfgWs{ + conn: protocol.NewWSConn(c), + } + + // 2 + if err := bfgapi.Write( + ctx, bws.conn, "someid", bitcoinBroadcastRequest, + ); err != nil { + t.Fatal(err) + } + + // XXX need a way to check duplicate in response, like bad request 400 + // command, _, _, err = bfgapi.Read(ctx, bws.conn) + // if err != nil { + // t.Fatal(err) + // } + + // if command != bfgapi.CmdBitcoinBroadcastResponse { + // t.Fatalf("unexpected command %s", command) + // } + + // // 5 + // if res.StatusCode != 400 { + // t.Fatalf("received bad status code %d, body %s", res.StatusCode, bodyString) + // } + + // if bodyString != "pop_basis insert failed: duplicate pop block entry: pq: duplicate key value violates unique constraint \"btc_txid_unconfirmed\"\n" { + // t.Fatalf("unexpected error: \"%s\"", bodyString) + // } +} + +// TestProcessBitcoinBlockNewBtcBlock mocks a btc block response from electrumx +// server and ensures that the btc block was inserted correctly +// 1 create mock electrumx server, by default it will send a mock btc block +// when blockchain.block.header is called +// 2 ensure that btc_block is inserted with correct values. this is checked on +// an internal timer, so give this a timeout +func TestProcessBitcoinBlockNewBtcBlock(t *testing.T) { + db, pgUri, sdb, cleanup := createTestDB(context.Background(), t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + ctx, cancel := defaultTestContext() + defer cancel() + + l2Keystone := hemi.L2Keystone{ + Version: 1, + L1BlockNumber: 5, + L2BlockNumber: 44, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + } + + // 1 + electrumxAddr, cleanupE := createMockElectrumxServer(ctx, t, &l2Keystone, nil) + defer cleanupE() + err := EnsureCanConnectTCP(t, electrumxAddr, mockElectrumxConnectTimeoutSeconds) + if err != nil { + t.Fatal(err) + } + + createBfgServer(ctx, t, pgUri, electrumxAddr, 1) + + expectedBtcBlockHeader, err := hex.DecodeString(strings.Replace(mockEncodedBlockHeader, "\"", "", 2)) + if err != nil { + t.Fatal(err) + } + + btcHeaderHash := btcchainhash.DoubleHashB(expectedBtcBlockHeader) + btcHeight := 2 + btcHeader := expectedBtcBlockHeader + + // 2 + // wait a max of 10 seconds (with a resolution of 1 second) for the + // btc_block to be inserted into the db. this happens on a timer + // when checking electrumx + _ctx, _cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer _cancel() + var _err error + var _btcBlockHeader *bfgd.BtcBlock + for { + select { + case <-_ctx.Done(): + break + case <-time.After(1 * time.Second): + _btcBlockHeader, _err = db.BtcBlockByHash(ctx, [32]byte(btcHeaderHash)) + if _err == nil { + break + } + } + + if _btcBlockHeader != nil { + break + } + } + + if _err != nil { + t.Fatal(_err) + } + + diff := deep.Equal(_btcBlockHeader, &bfgd.BtcBlock{ + Hash: btcHeaderHash, + Header: btcHeader, + Height: uint64(btcHeight), + }) + + if len(diff) > 0 { + t.Fatalf("unexpected diff %s", diff) + } +} + +// TestProcessBitcoinBlockNewFullPopBasis takes a full btc tx from the mock +// electrumx server and ensures that a new full pop_basis was inserted into the +// db +// 1 create btc tx +// 2 run mock electrumx, instructing it to use the created btc tx +// 3 query database for newly created pop_basis, this happens on a timer +// 4 ensure pop_basis was inserted and filled out with correct fields +func TestProcessBitcoinBlockNewFullPopBasis(t *testing.T) { + db, pgUri, sdb, cleanup := createTestDB(context.Background(), t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + ctx, cancel := defaultTestContext() + defer cancel() + + l2Keystone := hemi.L2Keystone{ + Version: 1, + L1BlockNumber: 5, + L2BlockNumber: 44, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + } + + // 1 + btx := createBtcTx(t, 199, &l2Keystone, []byte{1, 2, 3}) + + // 2 + electrumxAddr, cleanupE := createMockElectrumxServer(ctx, t, &l2Keystone, btx) + defer cleanupE() + err := EnsureCanConnectTCP(t, electrumxAddr, mockElectrumxConnectTimeoutSeconds) + if err != nil { + t.Fatal(err) + } + + createBfgServer(ctx, t, pgUri, electrumxAddr, 1) + + // 3 + // wait a max of 10 seconds (with a resolution of 1 second) for the + // btc_block to be inserted into the db. this happens on a timer + // when checking electrumx + _ctx, _cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer _cancel() + var _err error + var popBases []bfgd.PopBasis + for { + select { + case <-_ctx.Done(): + break + case <-time.After(1 * time.Second): + popBases, _err = db.PopBasisByL2KeystoneAbrevHash(ctx, [32]byte(hemi.L2KeystoneAbbreviate(l2Keystone).Hash()), false) + if _err == nil && len(popBases) > 0 { + break + } + } + + if len(popBases) > 0 { + break + } + } + + if _err != nil { + t.Fatal(_err) + } + + btcTxId, err := btcchainhash.NewHashFromStr(mockTxHash) + if err != nil { + t.Fatal(err) + } + + btcHeader, err := hex.DecodeString(strings.Replace(mockEncodedBlockHeader, "\"", "", 2)) + if err != nil { + t.Fatal(err) + } + + btcHeaderHash := btcchainhash.DoubleHashB(btcHeader) + + privateKey := dcrsecp256k1.PrivKeyFromBytes([]byte{1, 2, 3}) + publicKey := privateKey.PubKey() + publicKeyUncompressed := publicKey.SerializeUncompressed() + + var txIndex uint64 = 3 + + // 4 + btcTxIdSlice := btcTxId[:] + slices.Reverse(btcTxIdSlice) + + popTxIdFull := []byte{} + popTxIdFull = append(popTxIdFull, btcTxIdSlice...) + popTxIdFull = append(popTxIdFull, btcHeader...) + popTxIdFull = binary.AppendUvarint(popTxIdFull, 3) + + popTxId := btcchainhash.DoubleHashB(popTxIdFull) + + diff := deep.Equal([]bfgd.PopBasis{ + { + BtcTxId: btcTxIdSlice, + BtcHeaderHash: btcHeaderHash, + BtcTxIndex: &txIndex, + PopTxId: popTxId, + L2KeystoneAbrevHash: hemi.L2KeystoneAbbreviate(l2Keystone).Hash(), + BtcRawTx: btx, + PopMinerPublicKey: publicKeyUncompressed, + BtcMerklePath: mockMerkleHashes, + }, + }, popBases) + + if len(diff) > 0 { + t.Fatalf("unexpected diff %s", diff) + } +} + +// TestBitcoinBroadcastThenUpdate will insert a pop_basis record from +// BitcoinBroadcast RPC call to BFG. Then wait for electrumx to send full +// information about that pop_basis from a pop tx. then assert that the +// pop_basis was filled out correctly +// 1 create a btc tx +// 2 create a mock electrumx server that will return that btc tx +// 3 call BitcoinBroadcast RPC call +// 4 wait for full pop_basis to be in database +// 5 assert the pop_basis fields are correct +func TestBitcoinBroadcastThenUpdate(t *testing.T) { + db, pgUri, sdb, cleanup := createTestDB(context.Background(), t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + ctx, cancel := defaultTestContext() + defer cancel() + + l2Keystone := hemi.L2Keystone{ + Version: 1, + L1BlockNumber: 5, + L2BlockNumber: 44, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + } + + // 1 + btx := createBtcTx(t, 199, &l2Keystone, minerPrivateKeyBytes) + + // 2 + electrumxAddr, cleanupE := createMockElectrumxServer(ctx, t, &l2Keystone, btx) + defer cleanupE() + err := EnsureCanConnectTCP(t, electrumxAddr, mockElectrumxConnectTimeoutSeconds) + if err != nil { + t.Fatal(err) + } + + _, _, _, bfgPublicWsUrl := createBfgServer(ctx, t, pgUri, electrumxAddr, 1) + + c, _, err := websocket.Dial(ctx, bfgPublicWsUrl, nil) + if err != nil { + t.Fatal(err) + } + + defer c.CloseNow() + + privateKey := dcrsecp256k1.PrivKeyFromBytes(minerPrivateKeyBytes) + + authClient, err := auth.NewSecp256k1AuthClient(privateKey) + if err != nil { + t.Fatal(err) + } + + protocolConn := protocol.NewWSConn(c) + if err := authClient.HandshakeClient(ctx, protocolConn); err != nil { + t.Fatal(err) + } + assertPing(ctx, t, c, bfgapi.CmdPingRequest) + + bws := &bfgWs{ + conn: protocol.NewWSConn(c), + } + + bitcoinBroadcastRequest := bfgapi.BitcoinBroadcastRequest{ + Transaction: btx, + } + + if err := bfgapi.Write( + ctx, bws.conn, "someid", bitcoinBroadcastRequest, + ); err != nil { + t.Fatal(err) + } + + command, _, _, err := bfgapi.Read(ctx, bws.conn) + if err != nil { + t.Fatal(err) + } + + if command != bfgapi.CmdBitcoinBroadcastResponse { + t.Fatalf("received wrong command %s", command) + } + + publicKey := privateKey.PubKey() + publicKeyUncompressed := publicKey.SerializeUncompressed() + + btcTxId, err := btcchainhash.NewHashFromStr(mockTxHash) + if err != nil { + t.Fatal(err) + } + + // 4 + // wait a max of 10 seconds (with a resolution of 1 second) for the + // btc_block to be inserted into the db. this happens on a timer + // when checking electrumx + _ctx, _cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer _cancel() + var _err error + var popBases []bfgd.PopBasis + for { + select { + case <-_ctx.Done(): + break + case <-time.After(1 * time.Second): + popBases, _err = db.PopBasisByL2KeystoneAbrevHash(ctx, [32]byte(hemi.L2KeystoneAbbreviate(l2Keystone).Hash()), true) + if _err == nil && len(popBases) > 0 { + break + } + } + + if len(popBases) > 0 { + break + } + } + + if _err != nil { + t.Fatal(_err) + } + + btcHeader, err := hex.DecodeString(strings.Replace(mockEncodedBlockHeader, "\"", "", 2)) + if err != nil { + t.Fatal(err) + } + + btcHeaderHash := btcchainhash.DoubleHashB(btcHeader) + + btcTxIdSlice := btcTxId[:] + slices.Reverse(btcTxIdSlice) + + popTxIdFull := []byte{} + popTxIdFull = append(popTxIdFull, btcTxIdSlice...) + popTxIdFull = append(popTxIdFull, btcHeader...) + popTxIdFull = binary.AppendUvarint(popTxIdFull, 3) + + popTxId := btcchainhash.DoubleHashB(popTxIdFull) + + var txIndex uint64 = 3 + + // 5 + diff := deep.Equal([]bfgd.PopBasis{ + { + BtcTxId: btcTxIdSlice, + BtcHeaderHash: btcHeaderHash, + BtcTxIndex: &txIndex, + PopTxId: popTxId, + L2KeystoneAbrevHash: hemi.L2KeystoneAbbreviate(l2Keystone).Hash(), + BtcRawTx: btx, + PopMinerPublicKey: publicKeyUncompressed, + BtcMerklePath: mockMerkleHashes, + }, + }, popBases) + + if len(diff) > 0 { + t.Fatalf("unexpected diff %s", diff) + } +} + +// TestPopPayouts ensures that when querying for pop payouts by L2Keystone, +// we can filter out pop payouts not in that keystone and we can reduce +// mulitiple pop txs by the same miner to a single pop payout +// 1 create all of the pop txs via the pop_basis table, there will be (4) total, +// of those (1) will be filtered out, (2) will be for one pop miner, the remaining +// (1) will be for the other +// 2 query for the pop payouts by calling BSS.popPayouts +// 3 ensure the correct values +func TestPopPayouts(t *testing.T) { + db, pgUri, sdb, cleanup := createTestDB(context.Background(), t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + ctx, cancel := defaultTestContext() + defer cancel() + + privateKey := dcrsecp256k1.PrivKeyFromBytes([]byte{9, 8, 7}) + publicKey := privateKey.PubKey() + publicKeyUncompressed := publicKey.SerializeUncompressed() + minerHash := crypto.Keccak256(publicKeyUncompressed[1:]) + minerHash = minerHash[len(minerHash)-20:] + minerAddress := common.BytesToAddress(minerHash) + + privateKey = dcrsecp256k1.PrivKeyFromBytes([]byte{1, 2, 3}) + publicKey = privateKey.PubKey() + otherPublicKeyUncompressed := publicKey.SerializeUncompressed() + minerHash = crypto.Keccak256(otherPublicKeyUncompressed[1:]) + minerHash = minerHash[len(minerHash)-20:] + otherMinerAddress := common.BytesToAddress(minerHash) + + includedL2Keystone := hemi.L2Keystone{ + Version: 1, + L1BlockNumber: 11, + L2BlockNumber: 22, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + } + + differentL2Keystone := hemi.L2Keystone{ + Version: 1, + L1BlockNumber: 13, + L2BlockNumber: 23, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + } + + btcHeaderHash := fillOutBytes("btcheaderhash", 32) + + btcBlock := bfgd.BtcBlock{ + Hash: btcHeaderHash, + Header: fillOutBytes("btcheader", 80), + Height: 99, + } + + err := db.BtcBlockInsert(ctx, &btcBlock) + if err != nil { + t.Fatal(err) + } + + // insert 4 pop bases, 1 will have the "different" l2 keystone + // and be excluded from queries, the other 3 will be included, + // and will contain a duplicate pop miner address + + // 1 + var txIndex uint64 = 1 + + popBasis := bfgd.PopBasis{ + BtcTxId: fillOutBytes("btctxid1", 32), + BtcRawTx: []byte("btcrawtx1"), + PopTxId: fillOutBytes("poptxid1", 32), + L2KeystoneAbrevHash: hemi.L2KeystoneAbbreviate(includedL2Keystone).Hash(), + PopMinerPublicKey: publicKeyUncompressed, + BtcHeaderHash: btcHeaderHash, + BtcTxIndex: &txIndex, + } + + err = db.PopBasisInsertFull(ctx, &popBasis) + if err != nil { + t.Fatal(err) + } + + txIndex = 2 + + popBasis = bfgd.PopBasis{ + BtcTxId: fillOutBytes("btctxid2", 32), + BtcRawTx: []byte("btcrawtx2"), + PopTxId: fillOutBytes("poptxid2", 32), + L2KeystoneAbrevHash: hemi.L2KeystoneAbbreviate(includedL2Keystone).Hash(), + PopMinerPublicKey: otherPublicKeyUncompressed, + BtcHeaderHash: btcHeaderHash, + BtcTxIndex: &txIndex, + } + + err = db.PopBasisInsertFull(ctx, &popBasis) + if err != nil { + t.Fatal(err) + } + + txIndex = 3 + + popBasis = bfgd.PopBasis{ + BtcTxId: fillOutBytes("btctxid3", 32), + BtcRawTx: []byte("btcrawtx3"), + PopTxId: fillOutBytes("poptxid3", 32), + L2KeystoneAbrevHash: hemi.L2KeystoneAbbreviate(includedL2Keystone).Hash(), + PopMinerPublicKey: publicKeyUncompressed, + BtcHeaderHash: btcHeaderHash, + BtcTxIndex: &txIndex, + } + + err = db.PopBasisInsertFull(ctx, &popBasis) + if err != nil { + t.Fatal(err) + } + + txIndex = 4 + + popBasis = bfgd.PopBasis{ + BtcTxId: fillOutBytes("btctxid4", 32), + BtcRawTx: []byte("btcrawtx4"), + PopTxId: fillOutBytes("poptxid4", 32), + L2KeystoneAbrevHash: hemi.L2KeystoneAbbreviate(differentL2Keystone).Hash(), + PopMinerPublicKey: publicKeyUncompressed, + BtcHeaderHash: btcHeaderHash, + BtcTxIndex: &txIndex, + } + + err = db.PopBasisInsertFull(ctx, &popBasis) + if err != nil { + t.Fatal(err) + } + + _, _, bfgWsurl, _ := createBfgServer(ctx, t, pgUri, "", 1) + + _, _, bssWsurl := createBssServer(ctx, t, bfgWsurl) + + c, _, err := websocket.Dial(ctx, bssWsurl, nil) + if err != nil { + t.Fatal(err) + } + defer c.CloseNow() + + assertPing(ctx, t, c, bssapi.CmdPingRequest) + + bws := &bssWs{ + conn: protocol.NewWSConn(c), + } + + serializedL2Keystone := hemi.L2KeystoneAbbreviate(includedL2Keystone).Serialize() + + // 2 + popPayoutsRequest := bssapi.PopPayoutsRequest{ + L2BlockForPayout: serializedL2Keystone[:], + } + + err = bssapi.Write(ctx, bws.conn, "someid", popPayoutsRequest) + if err != nil { + t.Fatal(err) + } + + var v protocol.Message + err = wsjson.Read(ctx, c, &v) + if err != nil { + t.Fatal(err) + } + + if v.Header.Command != bssapi.CmdPopPayoutResponse { + t.Fatalf("received unexpected command: %s", v.Header.Command) + } + + popPayoutsResponse := bssapi.PopPayoutsResponse{} + err = json.Unmarshal(v.Payload, &popPayoutsResponse) + if err != nil { + t.Fatal(err) + } + + sortFn := func(a, b bssapi.PopPayout) int { + // find first differing byte in miner addresses and sort by that, + // this should lead to predictable ordering as + // miner addresses are unique here + + var ab byte = 0 + var bb byte = 0 + + for i := 0; i < len(a.MinerAddress); i++ { + ab = a.MinerAddress[i] + bb = b.MinerAddress[i] + if ab != bb { + break + } + } + + if ab > bb { + return -1 + } + + return 1 + } + + slices.SortFunc(popPayoutsResponse.PopPayouts, sortFn) + + // 3 + diff := deep.Equal(popPayoutsResponse.PopPayouts, []bssapi.PopPayout{ + { + Amount: big.NewInt(2 * hemi.HEMIBase), + MinerAddress: minerAddress, + }, + { + Amount: big.NewInt(1 * hemi.HEMIBase), + MinerAddress: otherMinerAddress, + }, + }) + + if len(diff) != 0 { + t.Fatalf("unexpected diff %s", diff) + } +} + +func TestGetMostRecentL2BtcFinalitiesBSS(t *testing.T) { + db, pgUri, sdb, cleanup := createTestDB(context.Background(), t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + ctx, cancel := defaultTestContext() + defer cancel() + + _, _, bfgWsurl, _ := createBfgServer(ctx, t, pgUri, "", 1000) + + _, _, bssWsurl := createBssServer(ctx, t, bfgWsurl) + + btcBlock := createBtcBlock(ctx, t, db, 1, 998, []byte{}, 1) // finality should be 1000 - 998 - 9 + 1 = -6 + createBtcBlock(ctx, t, db, 1, -1, []byte{}, 2) // finality should be 1000 - 1000 - 9 + 1 = -8 (unpublished) + createBtcBlock(ctx, t, db, 1, 1000, btcBlock.Hash, 3) // finality should be 1000 - 1000 - 9 + 1 = -8 + expectedFinalitiesDesc := []int32{-8, -8, -6} + + c, _, err := websocket.Dial(ctx, bssWsurl, nil) + if err != nil { + t.Fatal(err) + } + defer c.CloseNow() + + assertPing(ctx, t, c, bssapi.CmdPingRequest) + + bws := &bssWs{ + conn: protocol.NewWSConn(c), + } + + finalityRequest := bssapi.BTCFinalityByRecentKeystonesRequest{ + NumRecentKeystones: 100, + } + + err = bssapi.Write(ctx, bws.conn, "someid", finalityRequest) + if err != nil { + t.Fatal(err) + } + + var v protocol.Message + err = wsjson.Read(ctx, c, &v) + if err != nil { + t.Fatal(err) + } + + if v.Header.Command != bssapi.CmdBTCFinalityByRecentKeystonesResponse { + t.Fatalf("received unexpected command: %s", v.Header.Command) + } + + recentFinalities, err := db.L2BTCFinalityMostRecent(ctx, 100) + if err != nil { + t.Fatal(err) + } + + expectedResponse := []hemi.L2BTCFinality{} + for i, r := range recentFinalities { + f, err := hemi.L2BTCFinalityFromBfgd(&r, 0, 0) + if err != nil { + t.Fatal(err) + } + + f.BTCFinality = expectedFinalitiesDesc[i] + expectedResponse = append(expectedResponse, *f) + } + + expectedApiResponse := bssapi.BTCFinalityByRecentKeystonesResponse{ + L2BTCFinalities: expectedResponse, + } + + finalityResponse := bssapi.BTCFinalityByRecentKeystonesResponse{} + err = json.Unmarshal(v.Payload, &finalityResponse) + if err != nil { + t.Fatal(err) + } + + diff := deep.Equal(expectedApiResponse, finalityResponse) + if len(diff) > 0 { + t.Fatalf("unexpected diff %s", diff) + } +} + +func TestGetFinalitiesByL2KeystoneBSS(t *testing.T) { + db, pgUri, sdb, cleanup := createTestDB(context.Background(), t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + ctx, cancel := defaultTestContext() + defer cancel() + + _, _, bfgWsurl, _ := createBfgServer(ctx, t, pgUri, "", 1000) + + _, _, bssWsurl := createBssServer(ctx, t, bfgWsurl) + + btcBlock := createBtcBlock(ctx, t, db, 1, 998, []byte{}, 1) // finality should be 1000 - 998 - 9 + 1 = -6 + createBtcBlock(ctx, t, db, 1, -1, []byte{}, 2) // finality should be 1000 - 1000 - 9 + 1 = -8 (unpublished) + createBtcBlock(ctx, t, db, 1, 1000, btcBlock.Hash, 3) // finality should be 1000 - 1000 - 9 + 1 = -8 + expectedFinalitiesDesc := []int32{-8, -6} + + c, _, err := websocket.Dial(ctx, bssWsurl, nil) + if err != nil { + t.Fatal(err) + } + defer c.CloseNow() + + assertPing(ctx, t, c, bssapi.CmdPingRequest) + + bws := &bssWs{ + conn: protocol.NewWSConn(c), + } + + // first and second btcBlocks + recentFinalities, err := db.L2BTCFinalityMostRecent(ctx, 100) + if err != nil { + t.Fatal(err) + } + + l2Keystones := []hemi.L2Keystone{} + for _, r := range recentFinalities[1:] { + l, err := hemi.L2BTCFinalityFromBfgd(&r, 0, 0) + if err != nil { + t.Fatal(err) + } + l2Keystones = append(l2Keystones, l.L2Keystone) + } + + finalityRequest := bssapi.BTCFinalityByKeystonesRequest{ + L2Keystones: l2Keystones, + } + + err = bssapi.Write(ctx, bws.conn, "someid", finalityRequest) + if err != nil { + t.Fatal(err) + } + + var v protocol.Message + err = wsjson.Read(ctx, c, &v) + if err != nil { + t.Fatal(err) + } + + if v.Header.Command != bssapi.CmdBTCFinalityByKeystonesResponse { + t.Fatalf("received unexpected command: %s", v.Header.Command) + } + + expectedResponse := []hemi.L2BTCFinality{} + for i, r := range recentFinalities[1:] { + f, err := hemi.L2BTCFinalityFromBfgd(&r, 0, 0) + if err != nil { + t.Fatal(err) + } + + f.BTCFinality = expectedFinalitiesDesc[i] + expectedResponse = append(expectedResponse, *f) + } + + expectedApiResponse := bssapi.BTCFinalityByRecentKeystonesResponse{ + L2BTCFinalities: expectedResponse, + } + + finalityResponse := bssapi.BTCFinalityByRecentKeystonesResponse{} + err = json.Unmarshal(v.Payload, &finalityResponse) + if err != nil { + t.Fatal(err) + } + + diff := deep.Equal(expectedApiResponse, finalityResponse) + if len(diff) > 0 { + t.Fatalf("unexpected diff %s", diff) + } +} + +func TestGetFinalitiesByL2KeystoneBSSLowerServerHeight(t *testing.T) { + db, pgUri, sdb, cleanup := createTestDB(context.Background(), t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + ctx, cancel := defaultTestContext() + defer cancel() + + _, _, bfgWsurl, _ := createBfgServer(ctx, t, pgUri, "", 999) + + _, _, bssWsurl := createBssServer(ctx, t, bfgWsurl) + + btcBlock := createBtcBlock(ctx, t, db, 1, 998, []byte{}, 1) // finality should be 1000 - 998 - 9 + 1 = -6 + createBtcBlock(ctx, t, db, 1, -1, []byte{}, 2) // finality should be 1000 - 1000 - 9 + 1 = -8 (unpublished) + createBtcBlock(ctx, t, db, 1, 1000, btcBlock.Hash, 3) // finality should be 1000 - 1000 - 9 + 1 = -8 + expectedFinalitiesDesc := []int32{-8, -6} + + c, _, err := websocket.Dial(ctx, bssWsurl, nil) + if err != nil { + t.Fatal(err) + } + defer c.CloseNow() + + assertPing(ctx, t, c, bssapi.CmdPingRequest) + + bws := &bssWs{ + conn: protocol.NewWSConn(c), + } + + // first and second btcBlocks + recentFinalities, err := db.L2BTCFinalityMostRecent(ctx, 100) + if err != nil { + t.Fatal(err) + } + + l2Keystones := []hemi.L2Keystone{} + for _, r := range recentFinalities[1:] { + l, err := hemi.L2BTCFinalityFromBfgd(&r, 0, 0) + if err != nil { + t.Fatal(err) + } + l2Keystones = append(l2Keystones, l.L2Keystone) + } + + finalityRequest := bssapi.BTCFinalityByKeystonesRequest{ + L2Keystones: l2Keystones, + } + + err = bssapi.Write(ctx, bws.conn, "someid", finalityRequest) + if err != nil { + t.Fatal(err) + } + + var v protocol.Message + err = wsjson.Read(ctx, c, &v) + if err != nil { + t.Fatal(err) + } + + if v.Header.Command != bssapi.CmdBTCFinalityByKeystonesResponse { + t.Fatalf("received unexpected command: %s", v.Header.Command) + } + + expectedResponse := []hemi.L2BTCFinality{} + for i, r := range recentFinalities[1:] { + f, err := hemi.L2BTCFinalityFromBfgd(&r, 0, 0) + if err != nil { + t.Fatal(err) + } + + f.BTCFinality = expectedFinalitiesDesc[i] + expectedResponse = append(expectedResponse, *f) + } + + expectedApiResponse := bssapi.BTCFinalityByRecentKeystonesResponse{ + L2BTCFinalities: expectedResponse, + } + + finalityResponse := bssapi.BTCFinalityByRecentKeystonesResponse{} + err = json.Unmarshal(v.Payload, &finalityResponse) + if err != nil { + t.Fatal(err) + } + + diff := deep.Equal(expectedApiResponse, finalityResponse) + if len(diff) > 0 { + t.Fatalf("unexpected diff %s", diff) + } +} + +func TestGetMostRecentL2BtcFinalitiesBFG(t *testing.T) { + db, pgUri, sdb, cleanup := createTestDB(context.Background(), t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + ctx, cancel := defaultTestContext() + defer cancel() + + _, _, bfgWsurl, _ := createBfgServer(ctx, t, pgUri, "", 1000) + + btcBlock := createBtcBlock(ctx, t, db, 1, 998, []byte{}, 1) // finality should be 1000 - 998 - 9 + 1 = -6 + createBtcBlock(ctx, t, db, 1, -1, []byte{}, 2) // finality should be 1000 - 1000 - 9 + 1 = -8 (unpublished) + createBtcBlock(ctx, t, db, 1, 1000, btcBlock.Hash, 3) // finality should be 1000 - 1000 - 9 + 1 = -8 + expectedFinalitiesDesc := []int32{-8, -8, -6} + + c, _, err := websocket.Dial(ctx, bfgWsurl, nil) + if err != nil { + t.Fatal(err) + } + defer c.CloseNow() + + assertPing(ctx, t, c, bfgapi.CmdPingRequest) + + bws := &bfgWs{ + conn: protocol.NewWSConn(c), + } + + finalityRequest := bfgapi.BTCFinalityByRecentKeystonesRequest{ + NumRecentKeystones: 100, + } + + err = bfgapi.Write(ctx, bws.conn, "someid", finalityRequest) + if err != nil { + t.Fatal(err) + } + + var v protocol.Message + err = wsjson.Read(ctx, c, &v) + if err != nil { + t.Fatal(err) + } + + if v.Header.Command != bfgapi.CmdBTCFinalityByRecentKeystonesResponse { + t.Fatalf("received unexpected command: %s", v.Header.Command) + } + + recentFinalities, err := db.L2BTCFinalityMostRecent(ctx, 100) + if err != nil { + t.Fatal(err) + } + + expectedResponse := []hemi.L2BTCFinality{} + for i, r := range recentFinalities { + f, err := hemi.L2BTCFinalityFromBfgd(&r, 0, 0) + if err != nil { + t.Fatal(err) + } + + f.BTCFinality = expectedFinalitiesDesc[i] + expectedResponse = append(expectedResponse, *f) + } + + expectedApiResponse := bfgapi.BTCFinalityByRecentKeystonesResponse{ + L2BTCFinalities: expectedResponse, + } + + finalityResponse := bfgapi.BTCFinalityByRecentKeystonesResponse{} + err = json.Unmarshal(v.Payload, &finalityResponse) + if err != nil { + t.Fatal(err) + } + + diff := deep.Equal(expectedApiResponse, finalityResponse) + if len(diff) > 0 { + t.Fatalf("unexpected diff %s", diff) + } +} + +func TestGetFinalitiesByL2KeystoneBFG(t *testing.T) { + db, pgUri, sdb, cleanup := createTestDB(context.Background(), t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + ctx, cancel := defaultTestContext() + defer cancel() + + _, _, bfgWsurl, _ := createBfgServer(ctx, t, pgUri, "", 1000) + + btcBlock := createBtcBlock(ctx, t, db, 1, 998, []byte{}, 1) // finality should be 1000 - 998 - 9 + 1 = -6 + createBtcBlock(ctx, t, db, 1, -1, []byte{}, 2) // finality should be 1000 - 1000 - 9 + 1 = -8 (unpublished) + createBtcBlock(ctx, t, db, 1, 1000, btcBlock.Hash, 3) // finality should be 1000 - 1000 - 9 + 1 = -8 + expectedFinalitiesDesc := []int32{-8, -6} + + c, _, err := websocket.Dial(ctx, bfgWsurl, nil) + if err != nil { + t.Fatal(err) + } + defer c.CloseNow() + + assertPing(ctx, t, c, bfgapi.CmdPingRequest) + + bws := &bfgWs{ + conn: protocol.NewWSConn(c), + } + + // first and second btcBlocks + recentFinalities, err := db.L2BTCFinalityMostRecent(ctx, 100) + if err != nil { + t.Fatal(err) + } + + l2Keystones := []hemi.L2Keystone{} + for _, r := range recentFinalities[1:] { + l, err := hemi.L2BTCFinalityFromBfgd(&r, 0, 0) + if err != nil { + t.Fatal(err) + } + l2Keystones = append(l2Keystones, l.L2Keystone) + } + + finalityRequest := bfgapi.BTCFinalityByKeystonesRequest{ + L2Keystones: l2Keystones, + } + + err = bfgapi.Write(ctx, bws.conn, "someid", finalityRequest) + if err != nil { + t.Fatal(err) + } + + var v protocol.Message + err = wsjson.Read(ctx, c, &v) + if err != nil { + t.Fatal(err) + } + + if v.Header.Command != bfgapi.CmdBTCFinalityByKeystonesResponse { + t.Fatalf("received unexpected command: %s", v.Header.Command) + } + + expectedResponse := []hemi.L2BTCFinality{} + for i, r := range recentFinalities[1:] { + f, err := hemi.L2BTCFinalityFromBfgd(&r, 0, 0) + if err != nil { + t.Fatal(err) + } + + f.BTCFinality = expectedFinalitiesDesc[i] + expectedResponse = append(expectedResponse, *f) + } + + expectedApiResponse := bfgapi.BTCFinalityByRecentKeystonesResponse{ + L2BTCFinalities: expectedResponse, + } + + finalityResponse := bfgapi.BTCFinalityByRecentKeystonesResponse{} + err = json.Unmarshal(v.Payload, &finalityResponse) + if err != nil { + t.Fatal(err) + } + + diff := deep.Equal(expectedApiResponse, finalityResponse) + if len(diff) > 0 { + t.Fatalf("unexpected diff %s", diff) + } +} + +// TestNotifyOnNewBtcBlockBFGClients tests that upon getting a new btc block, +// in this case from (mock) electrumx, that a new btc block +// notification will be sent to all clients connected to BFG +// 1. connect client +// 2. wait for notification +func TestNotifyOnNewBtcBlockBFGClients(t *testing.T) { + db, pgUri, sdb, cleanup := createTestDB(context.Background(), t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + ctx, cancel := defaultTestContext() + defer cancel() + + l2Keystone := hemi.L2Keystone{ + Version: 1, + L1BlockNumber: 5, + L2BlockNumber: 44, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + } + + btx := createBtcTx(t, 199, &l2Keystone, minerPrivateKeyBytes) + + electrumxAddr, cleanupE := createMockElectrumxServer(ctx, t, &l2Keystone, btx) + defer cleanupE() + if err := EnsureCanConnectTCP( + t, + electrumxAddr, + mockElectrumxConnectTimeoutSeconds, + ); err != nil { + t.Fatal(err) + } + + _, _, bfgWsurl, _ := createBfgServer(ctx, t, pgUri, electrumxAddr, 1) + + // 1 + c, _, err := websocket.Dial(ctx, bfgWsurl, nil) + if err != nil { + t.Fatal(err) + } + defer c.CloseNow() + + assertPing(ctx, t, c, bfgapi.CmdPingRequest) + + // 2 + retries := 2 + found := false + for i := 0; i < retries; i++ { + // 2 + var v protocol.Message + if err = wsjson.Read(ctx, c, &v); err != nil { + panic(fmt.Sprintf("error reading from ws: %s", err)) + } + + if v.Header.Command == bfgapi.CmdBTCNewBlockNotification { + found = true + break + } + } + + if !found { + t.Fatalf("never received expected command: %s", bfgapi.CmdBTCNewBlockNotification) + } +} + +// TestNotifyOnNewBtcFinalityBFGClients tests that upon getting a new btc block, +// in this case from (mock) electrumx, that a finality notification will be sent +// to all clients connected to BFG +// 1. connect client +// 2. wait for notification +func TestNotifyOnNewBtcFinalityBFGClients(t *testing.T) { + db, pgUri, sdb, cleanup := createTestDB(context.Background(), t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + ctx, cancel := defaultTestContext() + defer cancel() + + l2Keystone := hemi.L2Keystone{ + Version: 1, + L1BlockNumber: 5, + L2BlockNumber: 44, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + } + + btx := createBtcTx(t, 199, &l2Keystone, minerPrivateKeyBytes) + + electrumxAddr, cleanupE := createMockElectrumxServer(ctx, t, &l2Keystone, btx) + defer cleanupE() + if err := EnsureCanConnectTCP( + t, + electrumxAddr, + mockElectrumxConnectTimeoutSeconds, + ); err != nil { + t.Fatal(err) + } + + _, _, bfgWsurl, _ := createBfgServer(ctx, t, pgUri, electrumxAddr, 1) + + // 1 + c, _, err := websocket.Dial(ctx, bfgWsurl, nil) + if err != nil { + t.Fatal(err) + } + defer c.CloseNow() + + assertPing(ctx, t, c, bfgapi.CmdPingRequest) + + retries := 2 + found := false + for i := 0; i < retries; i++ { + // 2 + var v protocol.Message + if err = wsjson.Read(ctx, c, &v); err != nil { + panic(fmt.Sprintf("error reading from ws: %s", err)) + } + + if v.Header.Command == bfgapi.CmdBTCFinalityNotification { + found = true + break + } + } + + if !found { + t.Fatalf("never received expected command: %s", bfgapi.CmdBTCFinalityNotification) + } +} + +func TestNotifyOnL2KeystonesBFGClients(t *testing.T) { + db, pgUri, sdb, cleanup := createTestDB(context.Background(), t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + ctx, cancel := defaultTestContext() + defer cancel() + + _, _, _, bfgPublicWsUrl := createBfgServer(ctx, t, pgUri, "", 1) + + c, _, err := websocket.Dial(ctx, bfgPublicWsUrl, nil) + if err != nil { + t.Fatal(err) + } + defer c.CloseNow() + + protocolConn := protocol.NewWSConn(c) + + if err := authClient.HandshakeClient(ctx, protocolConn); err != nil { + t.Fatal(err) + } + assertPing(ctx, t, c, bfgapi.CmdPingRequest) + + l2Keystone := bfgd.L2Keystone{ + Hash: fillOutBytes("somehashone", 32), + Version: 1, + L1BlockNumber: 11, + L2BlockNumber: 22, + ParentEPHash: fillOutBytes("parentephashone", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephashone", 32), + StateRoot: fillOutBytes("staterootone", 32), + EPHash: fillOutBytes("ephashone", 32), + } + + if err := db.L2KeystonesInsert(ctx, []bfgd.L2Keystone{ + l2Keystone, + }); err != nil { + t.Fatal(err) + } + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + for { + var v protocol.Message + if err = wsjson.Read(ctx, c, &v); err != nil { + panic(fmt.Sprintf("error reading from ws: %s", err)) + } + + if v.Header.Command == bfgapi.CmdL2KeystonesNotification { + return + } + } + }() + + wg.Wait() +} + +// TestNotifyOnNewBtcBlockBSSClients tests that upon getting a new btc block, +// in this case from (mock) electrumx, that a new btc notification +// will be sent to all clients connected to BSS +// 1. connect client +// 2. wait for notification +func TestNotifyOnNewBtcBlockBSSClients(t *testing.T) { + db, pgUri, sdb, cleanup := createTestDB(context.Background(), t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + ctx, cancel := defaultTestContext() + defer cancel() + + l2Keystone := hemi.L2Keystone{ + Version: 1, + L1BlockNumber: 5, + L2BlockNumber: 44, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + } + + btx := createBtcTx(t, 199, &l2Keystone, minerPrivateKeyBytes) + + electrumxAddr, cleanupE := createMockElectrumxServer(ctx, t, &l2Keystone, btx) + defer cleanupE() + if err := EnsureCanConnectTCP( + t, + electrumxAddr, + mockElectrumxConnectTimeoutSeconds, + ); err != nil { + t.Fatal(err) + } + + _, _, bfgWsurl, _ := createBfgServer(ctx, t, pgUri, electrumxAddr, 1) + _, _, bssWsurl := createBssServer(ctx, t, bfgWsurl) + + // 1 + c, _, err := websocket.Dial(ctx, bssWsurl, nil) + if err != nil { + t.Fatal(err) + } + defer c.CloseNow() + + assertPing(ctx, t, c, bssapi.CmdPingRequest) + + retries := 2 + found := false + for i := 0; i < retries; i++ { + // 2 + var v protocol.Message + if err = wsjson.Read(ctx, c, &v); err != nil { + panic(fmt.Sprintf("error reading from ws: %s", err)) + } + + if v.Header.Command == bssapi.CmdBTCNewBlockNotification { + found = true + break + } + } + + if !found { + t.Fatalf("never received expected command: %s", bssapi.CmdBTCNewBlockNotification) + } +} + +// TestNotifyOnNewBtcFinalityBSSClients tests that upon getting a new btc block, +// in this case from (mock) electrumx, that a new finality notification +// will be sent to all clients connected to BSS +// 1. connect client +// 2. wait for notification +func TestNotifyOnNewBtcFinalityBSSClients(t *testing.T) { + db, pgUri, sdb, cleanup := createTestDB(context.Background(), t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + ctx, cancel := defaultTestContext() + defer cancel() + + l2Keystone := hemi.L2Keystone{ + Version: 1, + L1BlockNumber: 5, + L2BlockNumber: 44, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + } + + btx := createBtcTx(t, 199, &l2Keystone, minerPrivateKeyBytes) + + electrumxAddr, cleanupE := createMockElectrumxServer(ctx, t, &l2Keystone, btx) + defer cleanupE() + if err := EnsureCanConnectTCP( + t, + electrumxAddr, + mockElectrumxConnectTimeoutSeconds, + ); err != nil { + t.Fatal(err) + } + + _, _, bfgWsurl, _ := createBfgServer(ctx, t, pgUri, electrumxAddr, 1) + _, _, bssWsurl := createBssServer(ctx, t, bfgWsurl) + + // 1 + c, _, err := websocket.Dial(ctx, bssWsurl, nil) + if err != nil { + t.Fatal(err) + } + defer c.CloseNow() + + assertPing(ctx, t, c, bssapi.CmdPingRequest) + + retries := 2 + found := false + for i := 0; i < retries; i++ { + // 2 + var v protocol.Message + if err = wsjson.Read(ctx, c, &v); err != nil { + panic(fmt.Sprintf("error reading from ws: %s", err)) + } + + if v.Header.Command == bssapi.CmdBTCFinalityNotification { + found = true + break + } + } + + if !found { + t.Fatalf("never received expected command: %s", bssapi.CmdBTCFinalityNotification) + } +} + +func TestNotifyMultipleBFGClients(t *testing.T) { + db, pgUri, sdb, cleanup := createTestDB(context.Background(), t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + ctx, cancel := defaultTestContext() + defer cancel() + + l2Keystone := hemi.L2Keystone{ + Version: 1, + L1BlockNumber: 5, + L2BlockNumber: 44, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + } + + btx := createBtcTx(t, 199, &l2Keystone, minerPrivateKeyBytes) + + electrumxAddr, cleanupE := createMockElectrumxServer(ctx, t, &l2Keystone, btx) + defer cleanupE() + if err := EnsureCanConnectTCP( + t, + electrumxAddr, + mockElectrumxConnectTimeoutSeconds, + ); err != nil { + t.Fatal(err) + } + + _, _, bfgWsurl, _ := createBfgServer(ctx, t, pgUri, electrumxAddr, 1) + + wg := sync.WaitGroup{} + + for i := 0; i < 10; i++ { + wg.Add(1) + go func(_i int) { + defer wg.Done() + c, _, err := websocket.Dial(ctx, bfgWsurl, nil) + if err != nil { + panic(err) + } + + // ensure we can safely close 1 and handle the rest + if _i == 5 { + c.CloseNow() + return + } else { + defer c.CloseNow() + } + + assertPing(ctx, t, c, bfgapi.CmdPingRequest) + + var v protocol.Message + if err = wsjson.Read(ctx, c, &v); err != nil { + panic(fmt.Sprintf("error reading from ws: %s", err)) + } + + if v.Header.Command != bfgapi.CmdBTCNewBlockNotification && + v.Header.Command != bfgapi.CmdBTCFinalityNotification { + panic(fmt.Sprintf("wrong command: %s", v.Header.Command)) + } + }(i) + } + + wg.Wait() +} + +func TestNotifyMultipleBSSClients(t *testing.T) { + db, pgUri, sdb, cleanup := createTestDB(context.Background(), t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + ctx, cancel := defaultTestContext() + defer cancel() + + l2Keystone := hemi.L2Keystone{ + Version: 1, + L1BlockNumber: 5, + L2BlockNumber: 44, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + } + + btx := createBtcTx(t, 199, &l2Keystone, minerPrivateKeyBytes) + + electrumxAddr, cleanupE := createMockElectrumxServer(ctx, t, &l2Keystone, btx) + defer cleanupE() + if err := EnsureCanConnectTCP( + t, + electrumxAddr, + mockElectrumxConnectTimeoutSeconds, + ); err != nil { + t.Fatal(err) + } + + _, _, bfgWsurl, _ := createBfgServer(ctx, t, pgUri, electrumxAddr, 1) + _, _, bssWsurl := createBssServer(ctx, t, bfgWsurl) + + wg := sync.WaitGroup{} + + for i := 0; i < 10; i++ { + wg.Add(1) + go func(_i int) { + defer wg.Done() + c, _, err := websocket.Dial(ctx, bssWsurl, nil) + if err != nil { + panic(err) + } + + // ensure we can safely close 1 and handle the rest + if _i == 5 { + c.CloseNow() + return + } else { + defer c.CloseNow() + } + + assertPing(ctx, t, c, bssapi.CmdPingRequest) + + var v protocol.Message + if err = wsjson.Read(ctx, c, &v); err != nil { + panic(fmt.Sprintf("error reading from ws: %s", err)) + } + + if v.Header.Command != bssapi.CmdBTCNewBlockNotification && + v.Header.Command != bssapi.CmdBTCFinalityNotification { + panic(fmt.Sprintf("wrong command: %s", v.Header.Command)) + } + }(i) + } + + wg.Wait() +} + +func TestBFGAuthNoKey(t *testing.T) { + db, pgUri, sdb, cleanup := createTestDB(context.Background(), t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + ctx, cancel := defaultTestContext() + defer cancel() + + if err := db.AccessPublicKeyInsert(ctx, &bfgd.AccessPublicKey{ + PublicKey: []byte("invalidkeyinvalidkeyinvalidkey111"), + }); err != nil { + t.Fatal(err) + } + + _, _, _, bfgPublicWsUrl := createBfgServerWithAuth(ctx, t, pgUri, "", 1, true) + + c, _, err := websocket.Dial(ctx, bfgPublicWsUrl, nil) + if err != nil { + t.Fatal(err) + } + defer c.CloseNow() + + var v interface{} + err = wsjson.Read(ctx, c, &v) + if err == nil { + t.Fatal("expecting error") + } +} + +func TestBFGAuthPing(t *testing.T) { + db, pgUri, sdb, cleanup := createTestDB(context.Background(), t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + ctx, cancel := defaultTestContext() + defer cancel() + + if err := db.AccessPublicKeyInsert(ctx, &bfgd.AccessPublicKey{ + PublicKey: privateKey.PubKey().SerializeCompressed(), + }); err != nil { + t.Fatal(err) + } + + _, _, _, bfgPublicWsUrl := createBfgServerWithAuth(ctx, t, pgUri, "", 1, true) + + c, _, err := websocket.Dial(ctx, bfgPublicWsUrl, nil) + if err != nil { + t.Fatal(err) + } + defer c.CloseNow() + + protocolConn := protocol.NewWSConn(c) + + if err := authClient.HandshakeClient(ctx, protocolConn); err != nil { + t.Fatal(err) + } + assertPing(ctx, t, c, bfgapi.CmdPingRequest) + + l2KeystonesRequest := bfgapi.L2KeystonesRequest{ + NumL2Keystones: 0, + } + + if err := bfgapi.Write(ctx, protocolConn, "someid", &l2KeystonesRequest); err != nil { + t.Fatal(err) + } + + command, id, _, err := bfgapi.Read(ctx, protocolConn) + if err != nil { + t.Fatal(err) + } + + if command != bfgapi.CmdL2KeystonesResponse { + t.Fatalf("incorrect command: %s", command) + } + + if id != "someid" { + t.Fatalf("incorrect id: %s", id) + } +} + +func TestBFGAuthPingThenRemoval(t *testing.T) { + db, pgUri, sdb, cleanup := createTestDB(context.Background(), t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + ctx, cancel := defaultTestContext() + defer cancel() + + if err := db.AccessPublicKeyInsert(ctx, &bfgd.AccessPublicKey{ + PublicKey: privateKey.PubKey().SerializeCompressed(), + }); err != nil { + t.Fatal(err) + } + + _, _, bfgWsPrivateUrl, bfgPublicWsUrl := createBfgServerWithAuth(ctx, t, pgUri, "", 1, true) + + c, _, err := websocket.Dial(ctx, bfgPublicWsUrl, nil) + if err != nil { + t.Fatal(err) + } + defer c.CloseNow() + + if err := authClient.HandshakeClient(ctx, protocol.NewWSConn(c)); err != nil { + t.Fatal(err) + } + assertPing(ctx, t, c, bfgapi.CmdPingRequest) + + privateC, _, err := websocket.Dial(ctx, bfgWsPrivateUrl, nil) + if err != nil { + t.Fatal(err) + } + defer privateC.CloseNow() + + bws := bfgWs{ + conn: protocol.NewWSConn(privateC), + } + + if err := bfgapi.Write(ctx, bws.conn, "someid", &bfgapi.AccessPublicKeyDeleteRequest{ + PublicKey: hex.EncodeToString(privateKey.PubKey().SerializeCompressed()), + }); err != nil { + t.Fatal(err) + } + + var v interface{} + err = wsjson.Read(ctx, c, &v) + if err != nil && !strings.Contains(err.Error(), "status = StatusCode(4100)") { + t.Fatal(err) + } + + if err == nil { + t.Fatal("expecting error") + } +} + +func TestBFGAuthWrongKey(t *testing.T) { + db, pgUri, sdb, cleanup := createTestDB(context.Background(), t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + ctx, cancel := defaultTestContext() + defer cancel() + + if err := db.AccessPublicKeyInsert(ctx, &bfgd.AccessPublicKey{ + PublicKey: []byte("invalidkeyinvalidkeyinvalidkey111"), + }); err != nil { + t.Fatal(err) + } + + _, _, _, bfgPublicWsUrl := createBfgServerWithAuth(ctx, t, pgUri, "", 1, true) + + c, _, err := websocket.Dial(ctx, bfgPublicWsUrl, nil) + if err != nil { + t.Fatal(err) + } + defer c.CloseNow() + + if err := authClient.HandshakeClient(ctx, protocol.NewWSConn(c)); err != nil { + t.Fatal(err) + } + + var v interface{} + err = wsjson.Read(ctx, c, &v) + if err != nil && !strings.Contains(err.Error(), "status = StatusCode(4100)") { + t.Fatal(err) + } + + if err == nil { + t.Fatal("expecting error") + } +} + +func TestAddInvalidAccessPublicKey(t *testing.T) { + db, pgUri, sdb, cleanup := createTestDB(context.Background(), t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + ctx, cancel := defaultTestContext() + defer cancel() + + _, _, bfgPrivateWsUrl, _ := createBfgServer(ctx, t, pgUri, "", 1) + + c, _, err := websocket.Dial(ctx, bfgPrivateWsUrl, nil) + if err != nil { + t.Fatal(err) + } + defer c.CloseNow() + + bws := &bfgWs{ + conn: protocol.NewWSConn(c), + } + + assertPing(ctx, t, c, bfgapi.CmdPingRequest) + + if err := bfgapi.Write(ctx, bws.conn, "someid", &bfgapi.AccessPublicKeyCreateRequest{ + PublicKey: "nope", + }); err != nil { + t.Fatal(err) + } + + command, _, v, err := bfgapi.Read(ctx, bws.conn) + if err != nil { + t.Fatal(err) + } + + if command != bfgapi.CmdAccessPublicKeyCreateResponse { + t.Fatalf("unexpected command %s", command) + } + + resp := v.(*bfgapi.AccessPublicKeyCreateResponse) + if resp.Error == nil { + t.Fatal("expecting error") + } + + if !strings.Contains(resp.Error.String(), "encoding/hex: invalid byte") { + t.Fatalf("unexpected error %s", resp.Error) + } +} + +func TestDeleteInvalidAccessPublicKey(t *testing.T) { + db, pgUri, sdb, cleanup := createTestDB(context.Background(), t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + ctx, cancel := defaultTestContext() + defer cancel() + + _, _, bfgPrivateWsUrl, _ := createBfgServer(ctx, t, pgUri, "", 1) + + c, _, err := websocket.Dial(ctx, bfgPrivateWsUrl, nil) + if err != nil { + t.Fatal(err) + } + defer c.CloseNow() + + bws := &bfgWs{ + conn: protocol.NewWSConn(c), + } + + assertPing(ctx, t, c, bfgapi.CmdPingRequest) + + if err := bfgapi.Write(ctx, bws.conn, "someid", &bfgapi.AccessPublicKeyDeleteRequest{ + PublicKey: "nope", + }); err != nil { + t.Fatal(err) + } + + command, _, v, err := bfgapi.Read(ctx, bws.conn) + if err != nil { + t.Fatal(err) + } + + if command != bfgapi.CmdAccessPublicKeyDeleteResponse { + t.Fatalf("unexpected command %s", command) + } + + resp := v.(*bfgapi.AccessPublicKeyDeleteResponse) + if resp.Error == nil { + t.Fatal("expecting error") + } + + if !strings.Contains(resp.Error.String(), "encoding/hex: invalid byte") { + t.Fatalf("unexpected error %s", resp.Error) + } +} + +func TestAddDuplicateAccessPublicKey(t *testing.T) { + db, pgUri, sdb, cleanup := createTestDB(context.Background(), t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + privateKeyOne, err := dcrsecp256k1.GeneratePrivateKey() + if err != nil { + t.Fatal(err) + } + + publicKeyOne := hex.EncodeToString(privateKeyOne.PubKey().SerializeCompressed()) + + ctx, cancel := defaultTestContext() + defer cancel() + + _, _, bfgPrivateWsUrl, _ := createBfgServer(ctx, t, pgUri, "", 1) + + c, _, err := websocket.Dial(ctx, bfgPrivateWsUrl, nil) + if err != nil { + t.Fatal(err) + } + defer c.CloseNow() + + bws := &bfgWs{ + conn: protocol.NewWSConn(c), + } + + assertPing(ctx, t, c, bfgapi.CmdPingRequest) + + if err := bfgapi.Write(ctx, bws.conn, "someid", &bfgapi.AccessPublicKeyCreateRequest{ + PublicKey: publicKeyOne, + }); err != nil { + t.Fatal(err) + } + + command, _, v, err := bfgapi.Read(ctx, bws.conn) + if err != nil { + t.Fatal(err) + } + + if command != bfgapi.CmdAccessPublicKeyCreateResponse { + t.Fatalf("unexpected command %s", command) + } + + resp := v.(*bfgapi.AccessPublicKeyCreateResponse) + if resp.Error != nil { + t.Fatal(err) + } + + if err := bfgapi.Write(ctx, bws.conn, "someid", &bfgapi.AccessPublicKeyCreateRequest{ + PublicKey: publicKeyOne, + }); err != nil { + t.Fatal(err) + } + + command, _, v, err = bfgapi.Read(ctx, bws.conn) + if err != nil { + t.Fatal(err) + } + + if command != bfgapi.CmdAccessPublicKeyCreateResponse { + t.Fatalf("unexpected command %s", command) + } + + resp = v.(*bfgapi.AccessPublicKeyCreateResponse) + if resp.Error == nil { + t.Fatal("expecting error") + } +} + +func TestDeleteAccessPublicKeyThatDoesNotExist(t *testing.T) { + db, pgUri, sdb, cleanup := createTestDB(context.Background(), t) + defer func() { + db.Close() + sdb.Close() + cleanup() + }() + + privateKeyOne, err := dcrsecp256k1.GeneratePrivateKey() + if err != nil { + t.Fatal(err) + } + + publicKeyOne := hex.EncodeToString(privateKeyOne.PubKey().SerializeCompressed()) + + ctx, cancel := defaultTestContext() + defer cancel() + + _, _, bfgPrivateWsUrl, _ := createBfgServer(ctx, t, pgUri, "", 1) + + c, _, err := websocket.Dial(ctx, bfgPrivateWsUrl, nil) + if err != nil { + t.Fatal(err) + } + defer c.CloseNow() + + bws := &bfgWs{ + conn: protocol.NewWSConn(c), + } + + assertPing(ctx, t, c, bfgapi.CmdPingRequest) + + if err := bfgapi.Write(ctx, bws.conn, "someid", &bfgapi.AccessPublicKeyDeleteRequest{ + PublicKey: publicKeyOne, + }); err != nil { + t.Fatal(err) + } + + command, _, v, err := bfgapi.Read(ctx, bws.conn) + if err != nil { + t.Fatal(err) + } + + if command != bfgapi.CmdAccessPublicKeyDeleteResponse { + t.Fatalf("unexpected command %s", command) + } + + resp := v.(*bfgapi.AccessPublicKeyDeleteResponse) + if resp.Error == nil { + t.Fatal("expecting error") + } +} + +func createBtcBlock(ctx context.Context, t *testing.T, db bfgd.Database, count int, height int, lastHash []byte, l2BlockNumber uint32) bfgd.BtcBlock { + header := make([]byte, 80) + hash := make([]byte, 32) + parentEpHash := make([]byte, 32) + prevKeystoneEpHash := make([]byte, 32) + stateRoot := make([]byte, 32) + epHash := make([]byte, 32) + btcTxId := make([]byte, 32) + btcRawTx := make([]byte, 32) + popMinerPublicKey := make([]byte, 32) + + if _, err := rand.Read(header); err != nil { + t.Fatal(err) + } + + if _, err := rand.Read(hash); err != nil { + t.Fatal(err) + } + + if _, err := rand.Read(btcTxId); err != nil { + t.Fatal(err) + } + + if _, err := rand.Read(stateRoot); err != nil { + t.Fatal(err) + } + + if len(lastHash) != 0 { + for k := 4; (k - 4) < 32; k++ { + header[k] = lastHash[k-4] + } + } + + btcBlock := bfgd.BtcBlock{ + Header: header, + Hash: hash, + Height: uint64(height), + } + + _l2Keystone := hemi.L2Keystone{ + ParentEPHash: parentEpHash, + PrevKeystoneEPHash: prevKeystoneEpHash, + StateRoot: stateRoot, + EPHash: epHash, + L2BlockNumber: l2BlockNumber, + } + + l2KeystoneAbrevHash := hemi.L2KeystoneAbbreviate(_l2Keystone).Hash() + l2Keystone := bfgd.L2Keystone{ + Hash: l2KeystoneAbrevHash, + ParentEPHash: parentEpHash, + PrevKeystoneEPHash: prevKeystoneEpHash, + StateRoot: stateRoot, + EPHash: epHash, + L2BlockNumber: l2BlockNumber, + } + + popBasis := bfgd.PopBasis{ + BtcTxId: btcTxId, + BtcRawTx: btcRawTx, + BtcHeaderHash: hash, + L2KeystoneAbrevHash: l2KeystoneAbrevHash, + PopMinerPublicKey: popMinerPublicKey, + } + + if height == -1 { + err := db.L2KeystonesInsert(ctx, []bfgd.L2Keystone{l2Keystone}) + if err != nil { + t.Fatal(err) + } + + err = db.PopBasisInsertPopMFields(ctx, &popBasis) + if err != nil { + t.Fatal(err) + } + + return bfgd.BtcBlock{} + } + + err := db.BtcBlockInsert(ctx, &btcBlock) + if err != nil { + t.Fatal(err) + } + + err = db.L2KeystonesInsert(ctx, []bfgd.L2Keystone{l2Keystone}) + if err != nil { + t.Fatal(err) + } + + err = db.PopBasisInsertFull(ctx, &popBasis) + if err != nil { + t.Fatal(err) + } + + return btcBlock +} diff --git a/e2e/mocktimism/Dockerfile b/e2e/mocktimism/Dockerfile new file mode 100644 index 00000000..df3e3a1f --- /dev/null +++ b/e2e/mocktimism/Dockerfile @@ -0,0 +1,9 @@ +FROM golang:1.22.0@sha256:03082deb6ae090a0caa4e4a8f666bc59715bc6fa67f5fd109f823a0c4e1efc2a + +WORKDIR /mocktimism + +COPY . . + +RUN go build -o ./mocktimism ./e2e/mocktimism + +CMD /mocktimism/mocktimism \ No newline at end of file diff --git a/e2e/mocktimism/mocktimism.go b/e2e/mocktimism/mocktimism.go new file mode 100644 index 00000000..d32b6fed --- /dev/null +++ b/e2e/mocktimism/mocktimism.go @@ -0,0 +1,124 @@ +package main + +import ( + "context" + "os" + "sync" + "time" + + "github.com/davecgh/go-spew/spew" + "github.com/hemilabs/heminetwork/api/bssapi" + "github.com/hemilabs/heminetwork/api/protocol" + "github.com/hemilabs/heminetwork/hemi" + "github.com/juju/loggo" + "nhooyr.io/websocket" +) + +const logLevel = "INFO" + +var log = loggo.GetLogger("mocktimism") + +func init() { + loggo.ConfigureLoggers(logLevel) +} + +type bssWs struct { + wg sync.WaitGroup + addr string + conn *protocol.WSConn +} + +// mocktimism is meant to be a temporary optimism mock that creates keystones +// at an interval, feel free to change to test. + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + c, _, err := websocket.Dial(ctx, os.Getenv("MOCKTIMISM_BSS_URL"), nil) + if err != nil { + panic(err) + } + defer func() { + if err := c.Close(websocket.StatusNormalClosure, ""); err != nil { + log.Errorf("error closing websocket: %s", err) + } + }() + bws := &bssWs{ + conn: protocol.NewWSConn(c), + } + + l2Keystone := hemi.L2Keystone{ + Version: 1, + L1BlockNumber: 11, + L2BlockNumber: 22, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + } + + go func() { + for { + cmd, _, response, err := bssapi.Read(ctx, bws.conn) + if err != nil { + return + } + + log.Infof("received command %s\n", cmd) + log.Infof("%v", spew.Sdump(response)) + } + }() + + go func() { + // create a new block every second, then view pop payouts and finalities + + firstL2Keystone := hemi.L2KeystoneAbbreviate(l2Keystone).Serialize() + + for { + l2KeystoneRequest := bssapi.L2KeystoneRequest{ + L2Keystone: l2Keystone, + } + + err = bssapi.Write(ctx, bws.conn, "someid", l2KeystoneRequest) + if err != nil { + log.Errorf("error: %s", err) + return + } + + l2Keystone.L1BlockNumber++ + l2Keystone.L2BlockNumber++ + + time.Sleep(1 * time.Second) + + err = bssapi.Write(ctx, bws.conn, "someotherid", bssapi.PopPayoutsRequest{ + L2BlockForPayout: firstL2Keystone[:], + }) + if err != nil { + log.Errorf("error: %s", err) + return + } + + err = bssapi.Write(ctx, bws.conn, "someotheridz", bssapi.BTCFinalityByRecentKeystonesRequest{ + NumRecentKeystones: 100, + }) + if err != nil { + log.Errorf("error: %s", err) + return + } + } + }() + + time.Sleep(10 * time.Minute) +} + +// fillOutBytes will take a string and return a slice of bytes +// with values from the string suffixed until a size with bytes '_' +func fillOutBytes(prefix string, size int) []byte { + result := []byte(prefix) + for len(result) < size { + result = append(result, '_') + } + + return result +} diff --git a/e2e/network_test.go b/e2e/network_test.go new file mode 100644 index 00000000..a9476d00 --- /dev/null +++ b/e2e/network_test.go @@ -0,0 +1,565 @@ +package e2e + +import ( + "context" + "fmt" + "io" + "os" + "slices" + "strconv" + "strings" + "sync" + "testing" + "time" + + btcchaincfg "github.com/btcsuite/btcd/chaincfg" + "github.com/davecgh/go-spew/spew" + "github.com/docker/docker/api/types/container" + "github.com/docker/go-connections/nat" + "github.com/hemilabs/heminetwork/api/bssapi" + "github.com/hemilabs/heminetwork/api/protocol" + "github.com/hemilabs/heminetwork/bitcoin" + "github.com/hemilabs/heminetwork/ethereum" + "github.com/hemilabs/heminetwork/hemi" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + "nhooyr.io/websocket" +) + +const ( + privateKey = "72a2c41c84147325ce3c0f37697ef1e670c7169063dda89be9995c3c5219740f" + hostGateway = "mylocalhost" +) + +type StdoutLogConsumer struct { + Name string // name of service +} + +func (t *StdoutLogConsumer) Accept(l testcontainers.Log) { + fmt.Printf("%s: %s", t.Name, string(l.Content)) +} + +type bssWs struct { + wg sync.WaitGroup + addr string + conn *protocol.WSConn +} + +func TestFullNetwork(t *testing.T) { + // only run this when this env is set, this is a very heavy test + envValue := os.Getenv("HEMI_RUN_NETWORK_TEST") + val, err := strconv.ParseBool(envValue) + if envValue != "" && err != nil { + t.Fatal(err) + } + + if !val { + t.Skip("skipping network test") + } + + // this test runs for a long time, give it a large timeout + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + // create the key pair for the pop miner + _, publicKey, btcAddress, err := bitcoin.KeysAndAddressFromHexString( + privateKey, + &btcchaincfg.TestNet3Params, + ) + if err != nil { + t.Fatal(err) + } + + // create the bictoind container running in regtest mode + bitcoindContainer := createBitcoind(ctx, t) + + _, err = bitcoindContainer.Host(ctx) + if err != nil { + t.Fatal(err) + } + + err = runBitcoinCommand( + ctx, + t, + bitcoindContainer, + []string{ + "bitcoin-cli", + "-regtest=1", + "-rpcuser=user", + "-rpcpassword=password", + "generatetoaddress", + "300", // need to generate a lot for greater chance to not spend coinbase + btcAddress.EncodeAddress(), + }) + if err != nil { + t.Fatal(err) + } + + bitcoindEndpoint, err := getEndpointWithRetries(ctx, bitcoindContainer, 5) + if err != nil { + t.Fatal(err) + } + + bitcoindEndpoint = "http://user:password@" + bitcoindEndpoint + + // create the electrumx container and connect it to bitcoind + electrumxContainer := createElectrumx(ctx, t, bitcoindEndpoint) + electrumxEndpoint, err := getEndpointWithRetries(ctx, electrumxContainer, 5) + if err != nil { + t.Fatal(err) + } + + // create the postgres container + postgresContainer := createPostgres(ctx, t) + + postgresEndpoint, err := getEndpointWithRetries(ctx, postgresContainer, 5) + if err != nil { + t.Fatal(err) + } + + postgresEndpoint = "postgres://postgres@" + postgresEndpoint + "/bfg?sslmode=disable" + + // create the bfg container, connect it to postgres and electrmux + bfgContainer := createBfg(ctx, t, postgresEndpoint, electrumxEndpoint) + + privatePort, err := nat.NewPort("tcp", "8080") + if err != nil { + t.Fatal(err) + } + + bfgPrivateEndpoint, err := getEndpointWithPortAndRetries(ctx, bfgContainer, 5, privatePort) + if err != nil { + t.Fatal(err) + } + + publicPort, err := nat.NewPort("tcp", "8383") + if err != nil { + t.Fatal(err) + } + bfgPublicEndpoint, err := getEndpointWithPortAndRetries(ctx, bfgContainer, 5, publicPort) + if err != nil { + t.Fatal(err) + } + + bfgPrivateEndpoint = fmt.Sprintf("ws://%s/v1/ws/private", bfgPrivateEndpoint) + bfgPublicEndpoint = fmt.Sprintf("http://%s/v1/ws/public", bfgPublicEndpoint) + + // create the bss container and connect it to bfg + bssContainer := createBss(ctx, t, bfgPrivateEndpoint) + + bssEndpoint, err := getEndpointWithRetries(ctx, bssContainer, 5) + if err != nil { + t.Fatal(err) + } + + bssEndpoint = fmt.Sprintf("http://%s/v1/ws", bssEndpoint) + + // connect to bss, this is what we will perform tests against + c, _, err := websocket.Dial(ctx, bssEndpoint, nil) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := c.Close(websocket.StatusNormalClosure, ""); err != nil { + t.Logf("error closing websocket: %s", err) + } + }() + bws := &bssWs{ + conn: protocol.NewWSConn(c), + } + + createPopm(ctx, t, bfgPublicEndpoint) + + l2Keystone := hemi.L2Keystone{ + Version: 1, + L1BlockNumber: 11, + L2BlockNumber: 22, + ParentEPHash: fillOutBytes("parentephash", 32), + PrevKeystoneEPHash: fillOutBytes("prevkeystoneephash", 32), + StateRoot: fillOutBytes("stateroot", 32), + EPHash: fillOutBytes("ephash", 32), + } + + popPayoutReceived := make(chan struct{}) + + go func() { + for { + // add Max's test cases here + // read responses from bss as we perform actions + cmd, _, response, err := bssapi.Read(ctx, bws.conn) + if err != nil { + return + } + + t.Logf("received command %s", cmd) + t.Logf("%v", spew.Sdump(response)) + + if cmd == bssapi.CmdPopPayoutResponse { + popPayoutResponse := response.(*bssapi.PopPayoutsResponse) + if len(popPayoutResponse.PopPayouts) == 0 { + continue + } + publicKeyB := publicKey.SerializeUncompressed() + minerAddress := ethereum.PublicKeyToAddress(publicKeyB) + t.Logf("equal addresses? %s ?= %s", minerAddress.String(), popPayoutResponse.PopPayouts[0].MinerAddress.String()) + if slices.Equal(minerAddress.Bytes(), popPayoutResponse.PopPayouts[0].MinerAddress.Bytes()) { + select { + case popPayoutReceived <- struct{}{}: + default: + } + } + } + } + }() + + go func() { + // create a new block every second, then view pop payouts and finalities + + firstL2Keystone := hemi.L2KeystoneAbbreviate(l2Keystone).Serialize() + + for { + l2KeystoneRequest := bssapi.L2KeystoneRequest{ + L2Keystone: l2Keystone, + } + + err = bssapi.Write(ctx, bws.conn, "someid", l2KeystoneRequest) + if err != nil { + t.Logf("error: %s", err) + return + } + + err = runBitcoinCommand(ctx, + t, + bitcoindContainer, + []string{ + "bitcoin-cli", + "-regtest=1", + "-rpcuser=user", + "-rpcpassword=password", + "generatetoaddress", + "1", + btcAddress.EncodeAddress(), + }, + ) + if err != nil { + t.Log(err) + return + } + + l2Keystone.L1BlockNumber++ + l2Keystone.L2BlockNumber++ + + time.Sleep(1 * time.Second) + + err = bssapi.Write(ctx, bws.conn, "someotherid", bssapi.PopPayoutsRequest{ + L2BlockForPayout: firstL2Keystone[:], + }) + if err != nil { + t.Logf("error: %s", err) + return + } + + err = bssapi.Write(ctx, bws.conn, "someotheridz", bssapi.BTCFinalityByRecentKeystonesRequest{ + NumRecentKeystones: 100, + }) + if err != nil { + t.Logf("error: %s", err) + return + } + } + }() + + select { + case <-popPayoutReceived: + t.Logf("got the pop payout!") + case <-ctx.Done(): + t.Fatal(ctx.Err().Error()) + } +} + +func createBitcoind(ctx context.Context, t *testing.T) testcontainers.Container { + req := testcontainers.ContainerRequest{ + Image: "kylemanna/bitcoind", + Cmd: []string{"bitcoind", "-regtest=1", "-rpcuser=user", "-rpcpassword=password", "-rpcallowip=0.0.0.0/0", "-rpcbind=0.0.0.0:18443", "-txindex=1"}, + ExposedPorts: []string{"18443/tcp"}, + WaitingFor: wait.ForLog("dnsseed thread exit").WithPollInterval(1 * time.Second), + LogConsumerCfg: &testcontainers.LogConsumerConfig{ + Consumers: []testcontainers.LogConsumer{&StdoutLogConsumer{ + Name: "bitcoind", + }}, + }, + Name: "bitcoind", + } + bitcoindContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + t.Fatal(err) + } + + return bitcoindContainer +} + +func createElectrumx(ctx context.Context, t *testing.T, bitcoindEndpoint string) testcontainers.Container { + bitcoindEndpoint = replaceHost(bitcoindEndpoint) + req := testcontainers.ContainerRequest{ + Image: "lukechilds/electrumx", + Env: map[string]string{ + "DAEMON_URL": bitcoindEndpoint, + "COIN": "BitcoinSegwit", + "NET": "regtest", + }, + ExposedPorts: []string{"50001/tcp"}, + WaitingFor: wait.ForLog("INFO:Daemon:daemon #1").WithPollInterval(1 * time.Second), + LogConsumerCfg: &testcontainers.LogConsumerConfig{ + Consumers: []testcontainers.LogConsumer{&StdoutLogConsumer{ + Name: "electrumx", + }}, + }, + Name: "electrumx", + HostConfigModifier: func(hc *container.HostConfig) { + hc.ExtraHosts = []string{ + fmt.Sprintf("%s:host-gateway", hostGateway), + } + }, + } + + electrumxContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + t.Fatal(err) + } + + return electrumxContainer +} + +func createPostgres(ctx context.Context, t *testing.T) testcontainers.Container { + req := testcontainers.ContainerRequest{ + Env: map[string]string{ + "POSTGRES_DB": "bfg", + "POSTGRES_HOST_AUTH_METHOD": "trust", + }, + ExposedPorts: []string{"5432/tcp"}, + WaitingFor: wait.ForLog("database system is ready to accept connections").WithPollInterval(1 * time.Second), + FromDockerfile: testcontainers.FromDockerfile{ + Context: "./..", + Dockerfile: "./e2e/postgres.Dockerfile", + PrintBuildLog: true, + }, + LogConsumerCfg: &testcontainers.LogConsumerConfig{ + Consumers: []testcontainers.LogConsumer{&StdoutLogConsumer{ + Name: "postgres", + }}, + }, + Name: "postgres", + HostConfigModifier: func(hc *container.HostConfig) { + hc.ExtraHosts = []string{ + fmt.Sprintf("%s:host-gateway", hostGateway), + } + }, + } + + postgresContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + t.Fatal(err) + } + + return postgresContainer +} + +func createBfg(ctx context.Context, t *testing.T, pgUri string, electrumxAddr string) testcontainers.Container { + pgUri = replaceHost(pgUri) + electrumxAddr = replaceHost(electrumxAddr) + req := testcontainers.ContainerRequest{ + Env: map[string]string{ + "BFG_POSTGRES_URI": pgUri, + "BFG_BTC_START_HEIGHT": "100", + "BFG_EXBTC_ADDRESS": electrumxAddr, + "BFG_LOG_LEVEL": "TRACE", + "BFG_PUBLIC_ADDRESS": ":8383", + "BFG_PRIVATE_ADDRESS": ":8080", + }, + ExposedPorts: []string{"8080/tcp", "8383/tcp"}, + WaitingFor: wait.ForExposedPort().WithPollInterval(1 * time.Second), + FromDockerfile: testcontainers.FromDockerfile{ + Context: "./..", + Dockerfile: "./docker/bfgd/Dockerfile", + PrintBuildLog: true, + }, + LogConsumerCfg: &testcontainers.LogConsumerConfig{ + Consumers: []testcontainers.LogConsumer{&StdoutLogConsumer{ + Name: "bfg", + }}, + }, + Name: "bfg", + HostConfigModifier: func(hc *container.HostConfig) { + hc.ExtraHosts = []string{ + fmt.Sprintf("%s:host-gateway", hostGateway), + } + }, + } + + bfgContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + t.Fatal(err) + } + + return bfgContainer +} + +func createBss(ctx context.Context, t *testing.T, bfgUrl string) testcontainers.Container { + bfgUrl = replaceHost(bfgUrl) + req := testcontainers.ContainerRequest{ + Env: map[string]string{ + "BSS_BFG_URL": bfgUrl, + "BSS_LOG_LEVEL": "TRACE", + "BSS_ADDRESS": ":8081", + }, + ExposedPorts: []string{"8081/tcp"}, + WaitingFor: wait.ForExposedPort().WithPollInterval(1 * time.Second), + FromDockerfile: testcontainers.FromDockerfile{ + Context: "./..", + Dockerfile: "./docker/bssd/Dockerfile", + PrintBuildLog: true, + }, + LogConsumerCfg: &testcontainers.LogConsumerConfig{ + Consumers: []testcontainers.LogConsumer{&StdoutLogConsumer{ + Name: "bss", + }}, + }, + Name: "bss", + HostConfigModifier: func(hc *container.HostConfig) { + hc.ExtraHosts = []string{ + fmt.Sprintf("%s:host-gateway", hostGateway), + } + }, + } + + bssContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + t.Fatal(err) + } + + return bssContainer +} + +func createPopm(ctx context.Context, t *testing.T, bfgUrl string) testcontainers.Container { + bfgUrl = replaceHost(bfgUrl) + req := testcontainers.ContainerRequest{ + Env: map[string]string{ + "POPM_BTC_PRIVKEY": privateKey, + "POPM_BFG_URL": bfgUrl, + "POPM_LOG_LEVEL": "TRACE", + }, + WaitingFor: wait.ForLog("Starting PoP miner with BTC address").WithPollInterval(1 * time.Second), + FromDockerfile: testcontainers.FromDockerfile{ + Context: "./..", + Dockerfile: "./docker/popmd/Dockerfile", + PrintBuildLog: true, + }, + LogConsumerCfg: &testcontainers.LogConsumerConfig{ + Consumers: []testcontainers.LogConsumer{&StdoutLogConsumer{ + Name: "popm", + }}, + }, + Name: "popm", + HostConfigModifier: func(hc *container.HostConfig) { + hc.ExtraHosts = []string{ + fmt.Sprintf("%s:host-gateway", hostGateway), + } + }, + } + + popmContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + t.Fatal(err) + } + + return popmContainer +} + +func getEndpointWithRetries(ctx context.Context, container testcontainers.Container, retries int) (string, error) { + backoff := 500 * time.Millisecond + var lastError error + for i := 0; i < retries; i++ { + endpoint, err := container.Endpoint(ctx, "") + if err != nil { + lastError = err + time.Sleep(backoff) + backoff = backoff * 2 + continue + } + return endpoint, nil + } + + return "", lastError +} + +func getEndpointWithPortAndRetries(ctx context.Context, container testcontainers.Container, retries int, port nat.Port) (string, error) { + backoff := 500 * time.Millisecond + var lastError error + for i := 0; i < retries; i++ { + endpoint, err := container.PortEndpoint(ctx, port, "") + if err != nil { + lastError = err + time.Sleep(backoff) + backoff = backoff * 2 + continue + } + return endpoint, nil + } + + return "", lastError +} + +// fillOutBytes will take a string and return a slice of bytes +// with values from the string suffixed until a size with bytes '_' +func fillOutBytes(prefix string, size int) []byte { + result := []byte(prefix) + for len(result) < size { + result = append(result, '_') + } + + return result +} + +func runBitcoinCommand(ctx context.Context, t *testing.T, bitcoindContainer testcontainers.Container, cmd []string) error { + exitCode, result, err := bitcoindContainer.Exec(ctx, cmd) + if err != nil { + return err + } + + buf := new(strings.Builder) + _, err = io.Copy(buf, result) + if err != nil { + return err + } + + t.Logf(buf.String()) + if exitCode != 0 { + return fmt.Errorf("error code received: %d", exitCode) + } + + return nil +} + +// replaceHost will replace the host that is returned from .Endpoint() with +// the hostname that resolves to the docker host (hostGateway) +func replaceHost(h string) string { + return strings.Replace(h, "localhost", hostGateway, 1) +} diff --git a/e2e/postgres.Dockerfile b/e2e/postgres.Dockerfile new file mode 100644 index 00000000..2bb87ad6 --- /dev/null +++ b/e2e/postgres.Dockerfile @@ -0,0 +1,3 @@ +FROM postgres:16@sha256:3648b6c2ac30de803a598afbaaef47851a6ee1795d74b4a5dcc09a22513b15c9 + +COPY ./database/bfgd/scripts/*.sql /docker-entrypoint-initdb.d/ \ No newline at end of file diff --git a/ethereum/ethereum.go b/ethereum/ethereum.go new file mode 100644 index 00000000..2ac49215 --- /dev/null +++ b/ethereum/ethereum.go @@ -0,0 +1,21 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package ethereum + +import ( + "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +func PublicKeyToAddress(publicKey []byte) common.Address { + hash := crypto.Keccak256(publicKey[1:]) + hash = hash[len(hash)-20:] + return common.BytesToAddress(hash) +} + +func AddressFromPrivateKey(privKey *secp256k1.PrivateKey) common.Address { + return PublicKeyToAddress(privKey.PubKey().SerializeUncompressed()) +} diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..7916aebe --- /dev/null +++ b/go.mod @@ -0,0 +1,81 @@ +module github.com/hemilabs/heminetwork + +go 1.21 + +require ( + github.com/btcsuite/btcd v0.24.0 + github.com/btcsuite/btcd/btcutil v1.1.5 + github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 + github.com/davecgh/go-spew v1.1.1 + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 + github.com/docker/docker v25.0.3+incompatible + github.com/docker/go-connections v0.5.0 + github.com/ethereum/go-ethereum v1.13.5 + github.com/go-test/deep v1.1.0 + github.com/juju/loggo v1.0.0 + github.com/lib/pq v1.10.9 + github.com/mitchellh/go-homedir v1.1.0 + github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 + github.com/prometheus/client_golang v1.18.0 + github.com/testcontainers/testcontainers-go v0.28.0 + nhooyr.io/websocket v1.8.10 +) + +require ( + dario.cat/mergo v1.0.0 // indirect + github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect + github.com/Microsoft/go-winio v0.6.1 // indirect + github.com/Microsoft/hcsshim v0.11.4 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect + github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f // indirect + github.com/cenkalti/backoff/v4 v4.2.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/containerd/containerd v1.7.13 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/cpuguy83/dockercfg v0.3.1 // indirect + github.com/decred/dcrd/crypto/blake256 v1.0.1 // indirect + github.com/distribution/reference v0.5.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/holiman/uint256 v1.2.4 // indirect + github.com/klauspost/compress v1.17.7 // indirect + github.com/lufia/plan9stats v0.0.0-20231016141302-07b5767bb0ed // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.5.0 // indirect + github.com/moby/sys/user v0.1.0 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/prometheus/client_model v0.6.0 // indirect + github.com/prometheus/common v0.47.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect + github.com/shirou/gopsutil/v3 v3.24.1 // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/tklauser/go-sysconf v0.3.13 // indirect + github.com/tklauser/numcpus v0.7.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.48.0 // indirect + go.opentelemetry.io/otel v1.23.1 // indirect + go.opentelemetry.io/otel/metric v1.23.1 // indirect + go.opentelemetry.io/otel/trace v1.23.1 // indirect + golang.org/x/crypto v0.19.0 // indirect + golang.org/x/exp v0.0.0-20240213143201-ec583247a57a // indirect + golang.org/x/mod v0.15.0 // indirect + golang.org/x/sys v0.17.0 // indirect + golang.org/x/tools v0.18.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240221002015-b0ce06bbee7c // indirect + google.golang.org/grpc v1.62.0 // indirect + google.golang.org/protobuf v1.32.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..0558ac47 --- /dev/null +++ b/go.sum @@ -0,0 +1,329 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/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.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= +github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/Microsoft/hcsshim v0.11.4 h1:68vKo2VN8DE9AdN4tnkWnmdhqdbpUFM8OF3Airm7fz8= +github.com/Microsoft/hcsshim v0.11.4/go.mod h1:smjE4dvqPX9Zldna+t5FG3rnoHhaB7QYxPRqGcpAD9w= +github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= +github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= +github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A= +github.com/btcsuite/btcd v0.24.0 h1:gL3uHE/IaFj6fcZSu03SvqPMSx7s/dPzfpG/atRwWdo= +github.com/btcsuite/btcd v0.24.0/go.mod h1:K4IDc1593s8jKXIF7yS7yCTSxrknB9z0STzc2j6XgE4= +github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA= +github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE= +github.com/btcsuite/btcd/btcec/v2 v2.3.2 h1:5n0X6hX0Zk+6omWcihdYvdAlGf2DfasC0GMf7DClJ3U= +github.com/btcsuite/btcd/btcec/v2 v2.3.2/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= +github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A= +github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE= +github.com/btcsuite/btcd/btcutil v1.1.5 h1:+wER79R5670vs/ZusMTF1yTcRYE5GUsFbdjdisflzM8= +github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ= +github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f h1:bAs4lUbRJpnnkd9VhRV3jjAVU7DJVjMaK+IsvSeZvFo= +github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= +github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= +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= +github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I= +github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +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/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/containerd v1.7.13 h1:wPYKIeGMN8vaggSKuV1X0wZulpMz4CrgEsZdaCyB6Is= +github.com/containerd/containerd v1.7.13/go.mod h1:zT3up6yTRfEUa6+GsITYIJNgSVL9NQ4x4h1RPzk0Wu4= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/cpuguy83/dockercfg v0.3.1 h1:/FpZ+JaygUR/lZP2NlFI2DVfrOEMAIKP5wWEJdoYe9E= +github.com/cpuguy83/dockercfg v0.3.1/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/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5ilcvdfma9wOH6Y= +github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= +github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= +github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0= +github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v25.0.3+incompatible h1:D5fy/lYmY7bvZa0XTZ5/UJPljor41F+vdyJG5luQLfQ= +github.com/docker/docker v25.0.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +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/ethereum/go-ethereum v1.13.5 h1:U6TCRciCqZRe4FPXmy1sMGxTfuk8P7u2UoinF3VbaFk= +github.com/ethereum/go-ethereum v1.13.5/go.mod h1:yMTu38GSuyxaYzQMViqNmQ1s3cE84abZexQmTgenWk0= +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.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/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/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= +github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +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= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +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.5/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.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= +github.com/holiman/uint256 v1.2.4 h1:jUc4Nk8fm9jZabQuqr2JzednajVmBpC+oiTiXZJEApU= +github.com/holiman/uint256 v1.2.4/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +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/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= +github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a/go.mod h1:UJSiEoRfvx3hP73CvoARgeLjaIOjybY9vj8PUPPFGeU= +github.com/juju/loggo v1.0.0 h1:Y6ZMQOGR9Aj3BGkiWx7HBbIx6zNwNkxhVNOHU2i1bl0= +github.com/juju/loggo v1.0.0/go.mod h1:NIXFioti1SmKAlKNuUwbMenNdef59IF52+ZzuOmHYkg= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= +github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg= +github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +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/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/lufia/plan9stats v0.0.0-20231016141302-07b5767bb0ed h1:036IscGBfJsFIgJQzlui7nK1Ncm0tp2ktmPj8xO4N/0= +github.com/lufia/plan9stats v0.0.0-20231016141302-07b5767bb0ed/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= +github.com/lunixbochs/vtclean v0.0.0-20160125035106-4fbf7632a2c6/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.0.6/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-isatty v0.0.0-20160806122752-66b8e73f3f5c/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +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/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= +github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= +github.com/moby/sys/user v0.1.0 h1:WmZ93f5Ux6het5iituh9x2zAG7NFY9Aqi49jjE1PaQg= +github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU= +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/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= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= +github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +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/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.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 h1:Ii+DKncOVM8Cu1Hc+ETb5K+23HdAMvESYE3ZJ5b5cMI= +github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE= +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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk= +github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA= +github.com/prometheus/client_model v0.6.0 h1:k1v3CzpSRUTrKMppY35TLwPvxHqBu0bYgxZzqGIgaos= +github.com/prometheus/client_model v0.6.0/go.mod h1:NTQHnmxFpouOD0DpvP4XujX3CdOAGQPoaGhyTchlyt8= +github.com/prometheus/common v0.47.0 h1:p5Cz0FNHo7SnWOmWmoRozVcjEp0bIVU8cV7OShpjL1k= +github.com/prometheus/common v0.47.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/shirou/gopsutil/v3 v3.24.1 h1:R3t6ondCEvmARp3wxODhXMTLC/klMa87h2PHUw5m7QI= +github.com/shirou/gopsutil/v3 v3.24.1/go.mod h1:UU7a2MSBQa+kW1uuDq8DeEBS8kmrnQwsv2b5O513rwU= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= +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/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 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/testcontainers/testcontainers-go v0.28.0 h1:1HLm9qm+J5VikzFDYhOd+Zw12NtOl+8drH2E8nTY1r8= +github.com/testcontainers/testcontainers-go v0.28.0/go.mod h1:COlDpUXbwW3owtpMkEB1zo9gwb1CoKVKlyrVPejF4AU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/go-sysconf v0.3.13 h1:GBUpcahXSpR2xN01jhkNAbTLRk2Yzgggk8IM08lq3r4= +github.com/tklauser/go-sysconf v0.3.13/go.mod h1:zwleP4Q4OehZHGn4CYZDipCgg9usW5IJePewFCGVEa0= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/tklauser/numcpus v0.7.0 h1:yjuerZP127QG9m5Zh/mSO4wqurYil27tHrqwRoRjpr4= +github.com/tklauser/numcpus v0.7.0/go.mod h1:bb6dMVcj8A42tSE7i32fsIUCbQNllK5iDguyOZRUzAY= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.48.0 h1:doUP+ExOpH3spVTLS0FcWGLnQrPct/hD/bCPbDRUEAU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.48.0/go.mod h1:rdENBZMT2OE6Ne/KLwpiXudnAsbdrdBaqBvTN8M8BgA= +go.opentelemetry.io/otel v1.23.1 h1:Za4UzOqJYS+MUczKI320AtqZHZb7EqxO00jAHE0jmQY= +go.opentelemetry.io/otel v1.23.1/go.mod h1:Td0134eafDLcTS4y+zQ26GE8u3dEuRBiBCTUIRHaikA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE= +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.23.1 h1:PQJmqJ9u2QaJLBOELl1cxIdPcpbwzbkjfEyelTl2rlo= +go.opentelemetry.io/otel/metric v1.23.1/go.mod h1:mpG2QPlAfnK8yNhNJAxDZruU9Y1/HubbC+KyH8FaCWI= +go.opentelemetry.io/otel/sdk v1.19.0 h1:6USY6zH+L8uMH8L3t1enZPR3WFEmSTADlqldyHtJi3o= +go.opentelemetry.io/otel/sdk v1.19.0/go.mod h1:NedEbbS4w3C6zElbLdPJKOpJQOrGUJ+GfzpjUvI0v1A= +go.opentelemetry.io/otel/trace v1.23.1 h1:4LrmmEd8AU2rFvU1zegmvqW7+kWarxtNOPyeL6HmYY8= +go.opentelemetry.io/otel/trace v1.23.1/go.mod h1:4IpnpJFwr1mo/6HL8XIPJaE9y0+u1KcVmuW7dwFSVrI= +go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= +go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= +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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/exp v0.0.0-20240213143201-ec583247a57a h1:HinSgX1tJRX3KsL//Gxynpw5CTOAIPhgL4W8PNiIpVE= +golang.org/x/exp v0.0.0-20240213143201-ec583247a57a/go.mod h1:CxmFvTBINI24O/j8iY7H1xHzx2i4OsyguNBmN/uPtqc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.15.0 h1:SernR4v+D55NyBH2QiEQrlBAnj1ECL6AGrA5+dPaMY8= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +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-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +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.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +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.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +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.1.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.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.18.0 h1:k8NLag8AGHnn+PHbl7g43CtqZAwG60vZkLqgyZgIHgQ= +golang.org/x/tools v0.18.0/go.mod h1:GL7B4CwcLLeo59yx/9UWWuNOW1n3VZ4f5axWfML7Lcg= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +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= +google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 h1:9+tzLLstTlPTRyJTh+ah5wIMsBW5c4tQwGTN3thOW9Y= +google.golang.org/genproto/googleapis/api v0.0.0-20240123012728-ef4313101c80 h1:Lj5rbfG876hIAYFjqiJnPHfhXbv+nzTWfm04Fg/XSVU= +google.golang.org/genproto/googleapis/api v0.0.0-20240123012728-ef4313101c80/go.mod h1:4jWUdICTdgc3Ibxmr8nAJiiLHwQBY0UI0XZcEMaFKaA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240221002015-b0ce06bbee7c h1:NUsgEN92SQQqzfA+YtqYNqYmB3DMMYLlIwUZAQFVFbo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240221002015-b0ce06bbee7c/go.mod h1:H4O17MA/PE9BsGx3w+a+W2VOLLD1Qf7oJneAoU6WktY= +google.golang.org/grpc v1.62.0 h1:HQKZ/fa1bXkX1oFOvSjmZEUL8wLSaZTjCcLAlmZRtdk= +google.golang.org/grpc v1.62.0/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE= +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.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.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= +google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20160105164936-4f90aeace3a2/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= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +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.0 h1:Ljk6PdHdOhAb5aDMWXjDLMMhph+BpztA4v1QdqEW2eY= +gotest.tools/v3 v3.5.0/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +nhooyr.io/websocket v1.8.10 h1:mv4p+MnGrLDcPlBoWsvPP7XCzTYMXP9F9eIGoKbgx7Q= +nhooyr.io/websocket v1.8.10/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= diff --git a/hemi/electrumx/electrumx.go b/hemi/electrumx/electrumx.go new file mode 100644 index 00000000..aa8d25bd --- /dev/null +++ b/hemi/electrumx/electrumx.go @@ -0,0 +1,358 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package electrumx + +import ( + "bufio" + "bytes" + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "strings" + "sync" + + btcchainhash "github.com/btcsuite/btcd/chaincfg/chainhash" + + "github.com/hemilabs/heminetwork/bitcoin" +) + +// https://electrumx.readthedocs.io/en/latest/protocol-basics.html + +type JSONRPCError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type JSONRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` + ID uint64 `json:"id"` +} + +type JSONRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + Error *JSONRPCError `json:"error,omitempty"` + Result json.RawMessage `json:"result"` + ID uint64 `json:"id"` +} + +func NewJSONRPCError(code int, msg string) *JSONRPCError { + return &JSONRPCError{Code: code, Message: msg} +} + +type RPCError string + +func (e RPCError) Error() string { + return string(e) +} + +type BlockNotOnDiskError struct { + err error +} + +func NewBlockNotOnDiskError(err error) BlockNotOnDiskError { + return BlockNotOnDiskError{err: err} +} + +func (e BlockNotOnDiskError) Error() string { + return e.err.Error() +} + +func (e BlockNotOnDiskError) Is(target error) bool { + _, ok := target.(BlockNotOnDiskError) + return ok +} + +func (e BlockNotOnDiskError) Unwrap() error { + return e.err +} + +type NoTxAtPositionError struct { + err error +} + +func NewNoTxAtPositionError(err error) NoTxAtPositionError { + return NoTxAtPositionError{err: err} +} + +func (e NoTxAtPositionError) Error() string { + return e.err.Error() +} + +func (e NoTxAtPositionError) Is(target error) bool { + _, ok := target.(NoTxAtPositionError) + return ok +} + +func (e NoTxAtPositionError) Unwrap() error { + return e +} + +var ( + ErrBlockNotOnDisk = NewBlockNotOnDiskError(errors.New("block not on disk")) + ErrNoTxAtPosition = NewNoTxAtPositionError(errors.New("no tx at position")) +) + +// Client implements an electrumx JSON RPC client. +type Client struct { + address string + id uint64 + mtx sync.Mutex +} + +// NewClient returns an initialised electrumx client. +func NewClient(address string) (*Client, error) { + return &Client{ + address: address, + }, nil +} + +func (c *Client) call(ctx context.Context, method string, params, result any) error { + var dialer net.Dialer + conn, err := dialer.DialContext(ctx, "tcp", c.address) + if err != nil { + return fmt.Errorf("failed to dial electrumx: %v", err) + } + defer conn.Close() + + c.mtx.Lock() + c.id++ + c.mtx.Unlock() + + req := &JSONRPCRequest{ + JSONRPC: "2.0", + Method: method, + ID: c.id, + } + if params != any(nil) { + b, err := json.Marshal(params) + if err != nil { + } + req.Params = b + } + b, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("failed to marshal request: %v", err) + } + b = append(b, byte('\n')) + if _, err := io.Copy(conn, bytes.NewReader(b)); err != nil { + return fmt.Errorf("failed to write request: %v", err) + } + + reader := bufio.NewReader(conn) + b, err = reader.ReadBytes('\n') + if err != nil { + return fmt.Errorf("failed to read response: %v", err) + } + + var resp JSONRPCResponse + if err := json.Unmarshal(b, &resp); err != nil { + return fmt.Errorf("failed to unmarshal response: %v", err) + } + if resp.ID != req.ID { + return fmt.Errorf("response ID differs from request ID (%d != %d)", resp.ID, req.ID) + } + if resp.Error != nil { + return RPCError(resp.Error.Message) + } + if err := json.Unmarshal(resp.Result, &result); err != nil { + return fmt.Errorf("failed to unmarshal result: %v", err) + } + + return nil +} + +type Balance struct { + Confirmed uint64 `json:"confirmed"` + Unconfirmed int64 `json:"unconfirmed"` +} + +type HeaderNotification struct { + Height uint64 `json:"height"` + BinaryHeader string `json:"hex"` +} + +type exUTXO struct { + Hash string `json:"tx_hash"` + Height uint64 `json:"height"` + Index uint64 `json:"tx_pos"` + Value uint64 `json:"value"` +} + +type UTXO struct { + Hash []byte + Height uint64 + Index uint32 + Value int64 +} + +func (c *Client) Balance(ctx context.Context, scriptHash []byte) (*Balance, error) { + hash, err := btcchainhash.NewHash(scriptHash) + if err != nil { + return nil, fmt.Errorf("invalid script hash: %v", err) + } + params := struct { + ScriptHash string `json:"scripthash"` + }{ + ScriptHash: hash.String(), + } + var balance Balance + if err := c.call(ctx, "blockchain.scripthash.get_balance", ¶ms, &balance); err != nil { + return nil, err + } + return &balance, nil +} + +func (c *Client) Broadcast(ctx context.Context, rtx []byte) ([]byte, error) { + params := struct { + RawTx string `json:"raw_tx"` + }{ + RawTx: hex.EncodeToString(rtx), + } + var txHashStr string + if err := c.call(ctx, "blockchain.transaction.broadcast", ¶ms, &txHashStr); err != nil { + return nil, err + } + txHash, err := btcchainhash.NewHashFromStr(txHashStr) + if err != nil { + return nil, fmt.Errorf("failed to decode transaction hash: %v", err) + } + return txHash[:], nil +} + +func (c *Client) Height(ctx context.Context) (uint64, error) { + hn := &HeaderNotification{} + if err := c.call(ctx, "blockchain.headers.subscribe", nil, hn); err != nil { + return 0, err + } + return hn.Height, nil +} + +func (c *Client) RawBlockHeader(ctx context.Context, height uint64) (*bitcoin.BlockHeader, error) { + params := struct { + Height uint64 `json:"height"` + CPHeight uint64 `json:"cp_height"` + }{ + Height: height, + CPHeight: 0, + } + var rbhStr string + if err := c.call(ctx, "blockchain.block.header", ¶ms, &rbhStr); err != nil { + return nil, fmt.Errorf("failed to get block header: %v", err) + } + rbh, err := hex.DecodeString(rbhStr) + if err != nil { + return nil, fmt.Errorf("failed to decode raw block header: %v", err) + } + return bitcoin.RawBlockHeaderFromSlice(rbh) +} + +func (c *Client) RawTransaction(ctx context.Context, txHash []byte) ([]byte, error) { + hash, err := btcchainhash.NewHash(txHash) + if err != nil { + return nil, fmt.Errorf("invalid transaction hash: %v", err) + } + params := struct { + TXHash string `json:"tx_hash"` + Verbose bool `json:"verbose"` + }{ + TXHash: hash.String(), + Verbose: false, + } + var rtxStr string + if err := c.call(ctx, "blockchain.transaction.get", ¶ms, &rtxStr); err != nil { + return nil, fmt.Errorf("failed to get transaction: %v", err) + } + rtx, err := hex.DecodeString(rtxStr) + if err != nil { + return nil, fmt.Errorf("failed to decode raw transaction: %v", err) + } + return rtx, nil +} + +func (c *Client) Transaction(ctx context.Context, txHash []byte) ([]byte, error) { + hash, err := btcchainhash.NewHash(txHash) + if err != nil { + return nil, fmt.Errorf("invalid transaction hash: %v", err) + } + params := struct { + TXHash string `json:"tx_hash"` + Verbose bool `json:"verbose"` + }{ + TXHash: hash.String(), + Verbose: true, + } + var txJSON json.RawMessage + if err := c.call(ctx, "blockchain.transaction.get", ¶ms, &txJSON); err != nil { + return nil, fmt.Errorf("failed to get transaction: %v", err) + } + return []byte(txJSON), nil +} + +func (c *Client) TransactionAtPosition(ctx context.Context, height, index uint64) ([]byte, []string, error) { + params := struct { + Height uint64 `json:"height"` + TXPos uint64 `json:"tx_pos"` + Merkle bool `json:"merkle"` + }{ + Height: height, + TXPos: index, + Merkle: true, + } + result := struct { + TXHash string `json:"tx_hash"` + Merkle []string `json:"merkle"` + }{} + if err := c.call(ctx, "blockchain.transaction.id_from_pos", ¶ms, &result); err != nil { + if strings.HasPrefix(err.Error(), "no tx at position ") { + return nil, nil, NewNoTxAtPositionError(err) + } else if strings.HasPrefix(err.Error(), "db error: DBError('block ") && strings.Contains(err.Error(), " not on disk ") { + return nil, nil, NewBlockNotOnDiskError(err) + } + return nil, nil, fmt.Errorf("failed to get transaction from block: %v", err) + } + + txHash, err := btcchainhash.NewHashFromStr(result.TXHash) + if err != nil { + return nil, nil, fmt.Errorf("failed to decode transaction hash: %v", err) + } + + return txHash[:], result.Merkle, nil +} + +func (c *Client) UTXOs(ctx context.Context, scriptHash []byte) ([]*UTXO, error) { + hash, err := btcchainhash.NewHash(scriptHash) + if err != nil { + return nil, fmt.Errorf("invalid script hash: %v", err) + } + params := struct { + ScriptHash string `json:"scripthash"` + }{ + ScriptHash: hash.String(), + } + var eutxos []*exUTXO + if err := c.call(ctx, "blockchain.scripthash.listunspent", ¶ms, &eutxos); err != nil { + return nil, err + } + var utxos []*UTXO + for _, eutxo := range eutxos { + hash, err := btcchainhash.NewHashFromStr(eutxo.Hash) + if err != nil { + return nil, fmt.Errorf("failed to decode UTXO hash: %v", err) + } + utxos = append(utxos, &UTXO{ + Hash: hash[:], + Height: eutxo.Height, + Index: uint32(eutxo.Index), + Value: int64(eutxo.Value), + }) + } + return utxos, nil +} diff --git a/hemi/hemi.go b/hemi/hemi.go new file mode 100644 index 00000000..4ad8b583 --- /dev/null +++ b/hemi/hemi.go @@ -0,0 +1,251 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package hemi + +import ( + "encoding/binary" + "fmt" + "io" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/juju/loggo" + + "github.com/hemilabs/heminetwork/api" + "github.com/hemilabs/heminetwork/database/bfgd" +) + +const ( + HeaderVersion1 = 1 + HeaderSize = 73 // XXX rename + KeystoneHeaderPeriod = 25 // XXX debate and set + + OldHeaderSize = 65 + + HEMIBase = 1000000000000000000 +) + +var log = loggo.GetLogger("hemi") + +type RawHeader [HeaderSize]byte + +// XXX Header should be renamed to L2KeystoneAbrev +type Header struct { + Version uint8 // 0:1 + BlockNumber uint32 // 1:5 + ParentEPHash [12]byte // 5:17 + PrevKeystoneEPHash [12]byte // 17:29 + StateRoot [32]byte // 29:61 + EPHash [12]byte // 61:73 +} + +type L2BTCFinality struct { + L2Keystone L2Keystone `json:"l2_keystone"` + BTCPubHeight int64 `json:"btc_pub_height"` + BTCPubHeaderHash api.ByteSlice `json:"btc_pub_header_hash"` + BTCFinality int32 `json:"btc_finality"` +} + +func L2BTCFinalityFromBfgd(l2BtcFinality *bfgd.L2BTCFinality, currentBTCHeight uint32, effectiveHeight uint32) (*L2BTCFinality, error) { + if effectiveHeight > currentBTCHeight { + return nil, fmt.Errorf("effective height greater than btc height (%d > %d)", effectiveHeight, currentBTCHeight) + } + + fin := int64(-9) + if effectiveHeight > 0 { + fin = int64(currentBTCHeight) - int64(effectiveHeight) - 9 + 1 + } + + // set a reasonable upper bound so we can safely convert to int32 + if fin > 100 { + fin = 100 + } + + return &L2BTCFinality{ + L2Keystone: L2Keystone{ + Version: uint8(l2BtcFinality.L2Keystone.Version), + L1BlockNumber: l2BtcFinality.L2Keystone.L1BlockNumber, + L2BlockNumber: l2BtcFinality.L2Keystone.L2BlockNumber, + ParentEPHash: api.ByteSlice(l2BtcFinality.L2Keystone.ParentEPHash), + PrevKeystoneEPHash: api.ByteSlice(l2BtcFinality.L2Keystone.PrevKeystoneEPHash), + StateRoot: api.ByteSlice(l2BtcFinality.L2Keystone.StateRoot), + EPHash: api.ByteSlice(l2BtcFinality.L2Keystone.EPHash), + }, + BTCPubHeight: l2BtcFinality.BTCPubHeight, + BTCPubHeaderHash: api.ByteSlice(l2BtcFinality.BTCPubHeaderHash), + BTCFinality: int32(fin), + }, nil +} + +func (h *Header) Dump(w io.Writer) { + fmt.Fprintf(w, "===========================\n") + fmt.Fprintf(w, "Version : %v\n", h.Version) + fmt.Fprintf(w, "Block Number : %v\n", h.BlockNumber) + fmt.Fprintf(w, "Parent EP Hash : %x\n", h.ParentEPHash) + fmt.Fprintf(w, "Previous Keystone EP Hash : %x\n", h.PrevKeystoneEPHash) + fmt.Fprintf(w, "State Root : %x\n", h.StateRoot) + fmt.Fprintf(w, "EP Hash : %x\n", h.EPHash) + fmt.Fprintf(w, "===========================\n") +} + +func (h *Header) Hash() []byte { + b := h.Serialize() + return chainhash.DoubleHashB(b[:]) +} + +func (h *Header) Serialize() RawHeader { + var rh RawHeader + rh[0] = h.Version + binary.BigEndian.PutUint32(rh[1:5], h.BlockNumber) + copy(rh[5:], h.ParentEPHash[:]) + copy(rh[17:], h.PrevKeystoneEPHash[:]) + copy(rh[29:], h.StateRoot[:]) + copy(rh[61:], h.EPHash[:]) + return rh +} + +func Genesis() *Header { + return &Header{Version: HeaderVersion1} +} + +func NewHeaderFromBytes(b []byte) (*Header, error) { + if len(b) < 1 { + return nil, fmt.Errorf("invalid header length (%d)", len(b)) + } + h := &Header{ + Version: b[0], + } + switch h.Version { + case HeaderVersion1: + if len(b) != HeaderSize { + return nil, fmt.Errorf("invalid header length (%d)", len(b)) + } + h.BlockNumber = binary.BigEndian.Uint32(b[1:5]) + copy(h.ParentEPHash[:], b[5:17]) + copy(h.PrevKeystoneEPHash[:], b[17:29]) + copy(h.StateRoot[:], b[29:61]) + copy(h.EPHash[:], b[61:73]) + default: + return nil, fmt.Errorf("unsuported version: %v", h.Version) + } + return h, nil +} + +// L2KeystoneVersion designates hwta version of the L2 keystone we are using. +const ( + L2KeystoneAbrevVersion uint8 = 1 + L2KeystoneAbrevSize = 76 +) + +// L2Keystone is the wire format of a keystone that is shared among several +// daemons. +type L2Keystone struct { + Version uint8 `json:"version"` + L1BlockNumber uint32 `json:"l1_block_number"` + L2BlockNumber uint32 `json:"l2_block_number"` + ParentEPHash api.ByteSlice `json:"parent_ep_hash"` + PrevKeystoneEPHash api.ByteSlice `json:"prev_ep_keystone_hash"` + StateRoot api.ByteSlice `json:"state_root"` + EPHash api.ByteSlice `json:"ep_hash"` +} + +// L2KeystoneAbrev is the abbreviated format of an L2Keystone. It simply clips +// various hashes to a shorter version. +type L2KeystoneAbrev struct { + Version uint8 // [0:1] + L1BlockNumber uint32 // [1:5] + L2BlockNumber uint32 // [5:9] + ParentEPHash [11]byte // [9:20] + PrevKeystoneEPHash [12]byte // [20:32] + StateRoot [32]byte // [32:64] + EPHash [12]byte // [64:76] +} + +func (a *L2KeystoneAbrev) Dump(w io.Writer) { + fmt.Fprintf(w, "===========================\n") + fmt.Fprintf(w, "Version : %v\n", a.Version) + fmt.Fprintf(w, "L1 Block Number : %v\n", a.L1BlockNumber) + fmt.Fprintf(w, "L2 Block Number : %x\n", a.L2BlockNumber) + fmt.Fprintf(w, "Parent EP hash : %x\n", a.ParentEPHash) + fmt.Fprintf(w, "Previous keystone EP Hash : %x\n", a.PrevKeystoneEPHash) + fmt.Fprintf(w, "State Root : %x\n", a.StateRoot) + fmt.Fprintf(w, "EP Hash : %x\n", a.EPHash) + fmt.Fprintf(w, "===========================\n") +} + +type RawAbreviatedL2Keystone [L2KeystoneAbrevSize]byte + +func (a *L2KeystoneAbrev) Serialize() RawAbreviatedL2Keystone { + var r RawAbreviatedL2Keystone + r[0] = a.Version + binary.BigEndian.PutUint32(r[1:5], a.L1BlockNumber) + binary.BigEndian.PutUint32(r[5:9], a.L2BlockNumber) + copy(r[9:], a.ParentEPHash[:]) + copy(r[20:], a.PrevKeystoneEPHash[:]) + copy(r[32:], a.StateRoot[:]) + copy(r[64:], a.EPHash[:]) + return r +} + +func L2KeystoneAbrevDeserialize(r RawAbreviatedL2Keystone) *L2KeystoneAbrev { + a := L2KeystoneAbrev{} + + a.Version = r[0] + a.L1BlockNumber = binary.BigEndian.Uint32(r[1:5]) + a.L2BlockNumber = binary.BigEndian.Uint32(r[5:9]) + a.ParentEPHash = [11]byte(r[9:20]) + a.PrevKeystoneEPHash = [12]byte(r[20:32]) + a.StateRoot = [32]byte(r[32:64]) + a.EPHash = [12]byte(r[64:76]) + + return &a +} + +func (a *L2KeystoneAbrev) Hash() []byte { + b := a.Serialize() + return chainhash.DoubleHashB(b[:]) +} + +func HashSerializedL2KeystoneAbrev(s []byte) []byte { + return chainhash.DoubleHashB(s) +} + +func L2KeystoneAbbreviate(l2ks L2Keystone) *L2KeystoneAbrev { + a := &L2KeystoneAbrev{ + Version: l2ks.Version, + L1BlockNumber: l2ks.L1BlockNumber, + L2BlockNumber: l2ks.L2BlockNumber, + } + copy(a.ParentEPHash[:], l2ks.ParentEPHash) + copy(a.PrevKeystoneEPHash[:], l2ks.PrevKeystoneEPHash) + copy(a.StateRoot[:], l2ks.StateRoot) + copy(a.EPHash[:], l2ks.EPHash) + + return a +} + +func NewL2KeystoneAbrevFromBytes(b []byte) (*L2KeystoneAbrev, error) { + if len(b) < 1 { + return nil, fmt.Errorf("invalid length (%d)", len(b)) + } + ka := &L2KeystoneAbrev{ + Version: b[0], + } + switch ka.Version { + case L2KeystoneAbrevVersion: + if len(b) != L2KeystoneAbrevSize { + return nil, fmt.Errorf("invalid keystone sbrev length (%d)", + len(b)) + } + ka.L1BlockNumber = binary.BigEndian.Uint32(b[1:5]) + ka.L2BlockNumber = binary.BigEndian.Uint32(b[5:9]) + copy(ka.ParentEPHash[:], b[9:20]) + copy(ka.PrevKeystoneEPHash[:], b[20:32]) + copy(ka.StateRoot[:], b[32:64]) + copy(ka.EPHash[:], b[64:76]) + default: + return nil, fmt.Errorf("unsuported version: %v", ka.Version) + } + return ka, nil +} diff --git a/hemi/hemi_test.go b/hemi/hemi_test.go new file mode 100644 index 00000000..528f2ed4 --- /dev/null +++ b/hemi/hemi_test.go @@ -0,0 +1,33 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package hemi + +import ( + "testing" + + "github.com/hemilabs/heminetwork/database/bfgd" +) + +func TestBtcFinalityZeroEffectiveHeight(t *testing.T) { + fin, err := L2BTCFinalityFromBfgd(&bfgd.L2BTCFinality{}, 1000, 0) + if err != nil { + t.Fatal(err) + } + + if fin.BTCFinality != -9 { + t.Fatalf("should have set finality to -9, received %d", fin) + } +} + +func TestBtcFinalityUpperBound(t *testing.T) { + fin, err := L2BTCFinalityFromBfgd(&bfgd.L2BTCFinality{}, 1000, 1) + if err != nil { + t.Fatal(err) + } + + if fin.BTCFinality != 100 { + t.Fatalf("should have set upper bound at 100, received %d", fin) + } +} diff --git a/hemi/pop/pop.go b/hemi/pop/pop.go new file mode 100644 index 00000000..c8957893 --- /dev/null +++ b/hemi/pop/pop.go @@ -0,0 +1,172 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package pop + +import ( + "bytes" + "encoding/hex" + "errors" + "fmt" + + "github.com/hemilabs/heminetwork/hemi" + + "github.com/btcsuite/btcd/txscript" + dcrsecp256k1 "github.com/decred/dcrd/dcrec/secp256k1/v4" +) + +var magic = []byte("HEMI") + +type MinerAddress [20]byte + +func MinerAddressFromString(address string) (*MinerAddress, error) { + b, err := hex.DecodeString(address) + if err != nil { + return nil, fmt.Errorf("invalid miner address: %v", err) + } + + var ma MinerAddress + if len(b) != len(ma) { + return nil, fmt.Errorf("invalid miner address length (%d != %d)", len(b), len(ma)) + } + copy(ma[:], b) + + return &ma, nil +} + +// XXX does this belong here? this feels more hemi-y. + +// TransactionL2 rename to Transaction and fixup this code +type TransactionL2 struct { + L2Keystone *hemi.L2KeystoneAbrev +} + +// Serialize serializes a PoP transaction to its byte representation. +func (tx *TransactionL2) Serialize() []byte { + khb := tx.L2Keystone.Serialize() + + var b []byte + b = append(b, magic...) + b = append(b, khb[:]...) + + return b +} + +// EncodeToOpReturn produces the pay to script necessary to publish this +// PoP transaction on Bitcoin using OP_RETURN. +func (tx *TransactionL2) EncodeToOpReturn() ([]byte, error) { + txb := tx.Serialize() + + tsb := txscript.NewScriptBuilder() + tsb.AddOp(txscript.OP_RETURN) + tsb.AddData(txb) + + return tsb.Script() +} + +// ParseTransactionFromOpReturn attempts to parse the given data +// as an OP_RETURN encoded PoP transaction. +func ParseTransactionL2FromOpReturn(script []byte) (*TransactionL2, error) { + txst := txscript.MakeScriptTokenizer(0, script) + if !txst.Next() { + return nil, errors.New("failed to parse script") + } + if txst.Opcode() != txscript.OP_RETURN { + return nil, fmt.Errorf("not a PoP transaction, found: 0x%X", txst.Opcode()) + } + if !txst.Next() { + return nil, errors.New("failed to parse script") + } + data := txst.Data() + if len(data) < 5 { + return nil, fmt.Errorf("not a PoP transaction, found len %d", len(data)) + } + if !bytes.Equal(data[0:4], magic) { + return nil, errors.New("not a PoP transaction") + } + ksh, err := hemi.NewL2KeystoneAbrevFromBytes(data[4:]) + if err != nil { + return nil, fmt.Errorf("failed to parse keystone header: %v", err) + } + return &TransactionL2{L2Keystone: ksh}, nil +} + +// XXX delete Transaction +type Transaction struct { + Keystone *hemi.Header +} + +// Serialize serializes a PoP transaction to its byte representation. +func (tx *Transaction) Serialize() []byte { + khb := tx.Keystone.Serialize() + + var b []byte + b = append(b, magic...) + b = append(b, khb[:]...) + + return b +} + +// EncodeToOpReturn produces the pay to script necessary to publish this +// PoP transaction on Bitcoin using OP_RETURN. +func (tx *Transaction) EncodeToOpReturn() ([]byte, error) { + txb := tx.Serialize() + + tsb := txscript.NewScriptBuilder() + tsb.AddOp(txscript.OP_RETURN) + tsb.AddData(txb) + + return tsb.Script() +} + +// ParseTransactionFromOpReturn attempts to parse the given data +// as an OP_RETURN encoded PoP transaction. +func ParseTransactionFromOpReturn(script []byte) (*Transaction, error) { + txst := txscript.MakeScriptTokenizer(0, script) + if !txst.Next() { + return nil, errors.New("failed to parse script") + } + if txst.Opcode() != txscript.OP_RETURN { + return nil, errors.New("not a PoP transaction") + } + if !txst.Next() { + return nil, errors.New("failed to parse script") + } + data := txst.Data() + if len(data) < 4 { + return nil, errors.New("not a PoP transaction") + } + if !bytes.Equal(data[0:4], magic) { + return nil, errors.New("not a PoP transaction") + } + ksh, err := hemi.NewHeaderFromBytes(data[4:]) + if err != nil { + return nil, fmt.Errorf("failed to parse keystone header: %v", err) + } + return &Transaction{Keystone: ksh}, nil +} + +func ParsePublicKeyFromSignatureScript(script []byte) ([]byte, error) { + var err error + txst := txscript.MakeScriptTokenizer(0, script) + if !txst.Next() { + return nil, errors.New("failed to parse script") + } + if txst.Opcode() != txscript.OP_DATA_72 && txst.Opcode() != txscript.OP_DATA_71 { + return nil, fmt.Errorf("not a signature , found: 0x%X", txst.Opcode()) + } + if !txst.Next() { + return nil, errors.New("failed to parse script") + } + data := txst.Data() + if len(data) != 33 { + return nil, fmt.Errorf("not a PoP transaction, found len %d", len(data)) + } + + publicKey, err := dcrsecp256k1.ParsePubKey(data) + if err != nil { + return nil, err + } + return publicKey.SerializeUncompressed(), nil +} diff --git a/hemi/pop/pop_test.go b/hemi/pop/pop_test.go new file mode 100644 index 00000000..d70fe194 --- /dev/null +++ b/hemi/pop/pop_test.go @@ -0,0 +1,197 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package pop + +import ( + "bytes" + "reflect" + "testing" + + "github.com/hemilabs/heminetwork/hemi" +) + +var ( + testKeystoneHeader = &hemi.Header{ + Version: 1, + BlockNumber: 8463, + ParentEPHash: [12]byte{ + 0xa3, 0x8c, 0x96, 0xeb, 0x92, 0xae, 0xb5, 0xea, + 0x26, 0xa4, 0xb8, 0x84, + }, + PrevKeystoneEPHash: [12]byte{ + 0x42, 0x76, 0x62, 0x5d, 0xa5, 0x0b, 0x3e, 0x1a, + 0x6f, 0xf2, 0xf0, 0x11, + }, + StateRoot: [32]byte{ + 0x5a, 0xee, 0x45, 0x44, 0xa9, 0xe6, 0xcf, 0x38, + 0x42, 0x76, 0x62, 0x5d, 0xa5, 0x0b, 0x3e, 0x1a, + 0xef, 0x34, 0x57, 0xbb, 0xa3, 0x8c, 0x96, 0xeb, + 0x92, 0xae, 0xb5, 0xea, 0x26, 0xa4, 0xb8, 0xfd, + }, + EPHash: [12]byte{ + 0xea, 0xee, 0x45, 0x44, 0xa9, 0xe6, 0xcf, 0x38, + 0xef, 0x34, 0x57, 0xbb, + }, + } + + testMinerAddress = &MinerAddress{ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, + 0x11, 0x12, 0x13, 0x14, + } + + testPoPTransaction = &Transaction{ + Keystone: testKeystoneHeader, + } + testPoPPayload = []byte{ + 0x6a, 0x4c, 0x4d, 0x48, 0x45, 0x4d, 0x49, 0x01, + 0x00, 0x00, 0x21, 0x0f, 0xa3, 0x8c, 0x96, 0xeb, + 0x92, 0xae, 0xb5, 0xea, 0x26, 0xa4, 0xb8, 0x84, + 0x42, 0x76, 0x62, 0x5d, 0xa5, 0x0b, 0x3e, 0x1a, + 0x6f, 0xf2, 0xf0, 0x11, 0x5a, 0xee, 0x45, 0x44, + 0xa9, 0xe6, 0xcf, 0x38, 0x42, 0x76, 0x62, 0x5d, + 0xa5, 0x0b, 0x3e, 0x1a, 0xef, 0x34, 0x57, 0xbb, + 0xa3, 0x8c, 0x96, 0xeb, 0x92, 0xae, 0xb5, 0xea, + 0x26, 0xa4, 0xb8, 0xfd, 0xea, 0xee, 0x45, 0x44, + 0xa9, 0xe6, 0xcf, 0x38, 0xef, 0x34, 0x57, 0xbb, + } +) + +func TestMinerAddressFromString(t *testing.T) { + tests := []struct { + address string + want *MinerAddress + }{ + { + address: "0102030405060708090a0b0c0d0e0f1011121314", + want: testMinerAddress, + }, + { + address: "", + want: nil, + }, + { + address: "0102030405060708090a0b0c0d0e0f101112131", + want: nil, + }, + { + address: "0102030405060708090a0b0c0d0e0f10111213", + want: nil, + }, + { + address: "0102030405060708090a0b0c0d0e0f101112131415", + want: nil, + }, + } + for _, test := range tests { + got, err := MinerAddressFromString(test.address) + switch { + case test.want == nil && err == nil: + t.Errorf("MinerAddressFromString(%q) succeeded, want error", test.address) + case test.want != nil && err != nil: + t.Errorf("MinerAddressFromString(%q) failed: %v", test.address, err) + case test.want != nil && err == nil: + if !bytes.Equal(got[:], test.want[:]) { + t.Errorf("MinerAddressFromString(%q) = %x, want %x", test.address, got, test.want) + } + } + } +} + +func TestTransactionSerialize(t *testing.T) { + ptx := testPoPTransaction + + got := ptx.Serialize() + + want := []byte{ + 0x48, 0x45, 0x4d, 0x49, 0x01, 0x00, 0x00, 0x21, + 0x0f, 0xa3, 0x8c, 0x96, 0xeb, 0x92, 0xae, 0xb5, + 0xea, 0x26, 0xa4, 0xb8, 0x84, 0x42, 0x76, 0x62, + 0x5d, 0xa5, 0x0b, 0x3e, 0x1a, 0x6f, 0xf2, 0xf0, + 0x11, 0x5a, 0xee, 0x45, 0x44, 0xa9, 0xe6, 0xcf, + 0x38, 0x42, 0x76, 0x62, 0x5d, 0xa5, 0x0b, 0x3e, + 0x1a, 0xef, 0x34, 0x57, 0xbb, 0xa3, 0x8c, 0x96, + 0xeb, 0x92, 0xae, 0xb5, 0xea, 0x26, 0xa4, 0xb8, + 0xfd, 0xea, 0xee, 0x45, 0x44, 0xa9, 0xe6, 0xcf, + 0x38, 0xef, 0x34, 0x57, 0xbb, + } + if !bytes.Equal(got, want) { + t.Errorf("Got serialized PoP transaction %x, want %x", got, want) + } +} + +func TestEncodeToOpReturn(t *testing.T) { + ptx := testPoPTransaction + + got, err := ptx.EncodeToOpReturn() + if err != nil { + t.Fatalf("Failed to encode PoP transaction: %v", err) + } + want := testPoPPayload + if !bytes.Equal(got, want) { + t.Errorf("Got encoded PoP transaction %x, want %x", got, want) + } +} + +func TestParseTransactionFromOpReturn(t *testing.T) { + tests := []struct { + script []byte + wantKeystone *hemi.Header + wantErr bool + }{ + { + script: testPoPPayload, + wantKeystone: testKeystoneHeader, + }, + { + script: []byte{0x6a}, + wantErr: true, + }, + { + script: []byte{0x6a, 0x01, 0x42}, + wantErr: true, + }, + { + script: []byte{0x6a, 0x02, 0x42, 0x56}, + wantErr: true, + }, + { + script: []byte{0x6a, 0x03, 0x42, 0x56, 0x4d}, + wantErr: true, + }, + { + script: []byte{0x6a, 0x04, 0x42, 0x56, 0x4d, 0x01}, + wantErr: true, + }, + { + script: []byte{ + 0x6a, 0x43, 0x42, 0x56, 0x4d, 0x01, 0x5a, 0xee, + 0x45, 0x44, 0xa9, 0xe6, 0xcf, 0x38, 0xef, 0x34, + 0x57, 0xbb, 0xa3, 0x8c, 0x96, 0xeb, 0x92, 0xae, + 0xb5, 0xea, 0x26, 0xa4, 0xb8, 0xfd, 0x00, 0x00, + 0x21, 0x0f, 0xa3, 0x8c, 0x96, 0xeb, 0x92, 0xae, + 0xb5, 0xea, 0x26, 0xa4, 0xb8, 0x84, 0x42, 0x76, + 0x62, 0x5d, 0xa5, 0x0b, 0x3e, 0x1a, 0x6f, 0xf2, + 0xf0, 0x11, 0xea, 0xee, 0x45, 0x44, 0xa9, 0xe6, + 0xcf, 0x38, 0xef, 0x34, 0x57, 0xbb, + }, + wantErr: true, + }, + } + + for i, test := range tests { + got, err := ParseTransactionFromOpReturn(test.script) + switch { + case err == nil && test.wantErr: + t.Errorf("Test %d - successfully parsed transaction, want error", i) + case err != nil && !test.wantErr: + t.Errorf("Test %d - failed to parse transaction: %v", i, err) + case err == nil && !test.wantErr: + if !reflect.DeepEqual(got.Keystone, test.wantKeystone) { + t.Errorf("Test %d - transaction successfully parsed, but keystone differs", i) + } + } + } +} diff --git a/service/bfg/bfg.go b/service/bfg/bfg.go new file mode 100644 index 00000000..305a145e --- /dev/null +++ b/service/bfg/bfg.go @@ -0,0 +1,1492 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package bfg + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "net" + "net/http" + "sync" + "time" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + btcwire "github.com/btcsuite/btcd/wire" + "github.com/davecgh/go-spew/spew" + "github.com/juju/loggo" + "github.com/prometheus/client_golang/prometheus" + "nhooyr.io/websocket" + + "github.com/hemilabs/heminetwork/api" + "github.com/hemilabs/heminetwork/api/auth" + "github.com/hemilabs/heminetwork/api/bfgapi" + "github.com/hemilabs/heminetwork/api/protocol" + "github.com/hemilabs/heminetwork/bitcoin" + "github.com/hemilabs/heminetwork/database" + "github.com/hemilabs/heminetwork/database/bfgd" + "github.com/hemilabs/heminetwork/database/bfgd/postgres" + "github.com/hemilabs/heminetwork/hemi" + "github.com/hemilabs/heminetwork/hemi/electrumx" + "github.com/hemilabs/heminetwork/hemi/pop" + "github.com/hemilabs/heminetwork/service/deucalion" +) + +// XXX this code needs to be a bit smarter when syncing bitcoin. We should +// return a "not ready" error whe that is the case. + +type notificationId string + +const ( + logLevel = "INFO" + + promSubsystem = "bfg_service" // Prometheus + + btcFinalityDelay = 9 + + notifyBtcBlocks notificationId = "btc_blocks" + notifyBtcFinalities notificationId = "btc_finalities" + notifyL2Keystones notificationId = "l2_keystones" +) + +var log = loggo.GetLogger("bfg") + +func init() { + loggo.ConfigureLoggers(logLevel) +} + +// InternalError is an error type to differentiates between caller and callee +// errors. An internal error is used whne something internal to the application +// fails. +type InternalError struct { + internal *protocol.Error + actual error +} + +// Err return the protocol.Error that can be sent over the wire. +func (ie InternalError) Err() *protocol.Error { + return ie.internal +} + +// String return the actual underlying error. +func (ie InternalError) String() string { + i := ie.internal + return fmt.Sprintf("%v [%v:%v]", ie.actual.Error(), i.Trace, i.Timestamp) +} + +// Error satifies the error interface. +func (ie InternalError) Error() string { + if ie.internal == nil { + return "internal error" + } + return ie.internal.String() +} + +func NewInternalErrorf(msg string, args ...interface{}) *InternalError { + return &InternalError{ + internal: protocol.Errorf("internal error"), + actual: fmt.Errorf(msg, args...), + } +} + +func NewDefaultConfig() *Config { + return &Config{ + EXBTCAddress: "localhost:18001", + PrivateListenAddress: ":8080", + PublicListenAddress: ":8383", + } +} + +// XXX this needs documenting. It isn't obvious if this needs tags or not +// because of lack of documentation. +type popTX struct { + btcHeight uint64 + keystone *hemi.Header + merkleHashes [][]byte + popMinerPublicKey []byte + rawBlockHeader []byte + rawTransaction []byte + txHash []byte + txIndex uint32 +} + +// XXX figure out if this needs to be moved out into the electrumx package. +type btcClient interface { + Balance(ctx context.Context, scriptHash []byte) (*electrumx.Balance, error) + Broadcast(ctx context.Context, rtx []byte) ([]byte, error) + Height(ctx context.Context) (uint64, error) + RawBlockHeader(ctx context.Context, height uint64) (*bitcoin.BlockHeader, error) + RawTransaction(ctx context.Context, txHash []byte) ([]byte, error) + Transaction(ctx context.Context, txHash []byte) ([]byte, error) + TransactionAtPosition(ctx context.Context, height, index uint64) ([]byte, []string, error) + UTXOs(ctx context.Context, scriptHash []byte) ([]*electrumx.UTXO, error) +} + +type Config struct { + BTCStartHeight uint64 + EXBTCAddress string + PrivateListenAddress string + PublicListenAddress string + LogLevel string + PgURI string + PrometheusListenAddress string + PublicKeyAuth bool +} + +type Server struct { + mtx sync.RWMutex + wg sync.WaitGroup + + cfg *Config + + btcHeight uint64 + hemiHeight uint32 + + // PoP transactions by BTC finality block height. + popTXFinality map[uint64][]*popTX // XXX does this need to go away? either because of persistence (thus read from disk every time) or because bitcoin finality notifications are going away + + // PoP transactions that have reached finality, sorted + // by HEMI keystone block height. PoP transactions will + // be added to this slice if they reach BTC finality, + // however we're missing a HEMI keystone. + popTXAtFinality []*popTX // XXX see previous XXX + + keystonesLock sync.RWMutex // XXX this probably needs to be an sql query + keystones []*hemi.Header + + server *http.ServeMux + publicServer *http.ServeMux + + btcClient btcClient // XXX evaluate if this is ok + + db bfgd.Database + + // Prometheus + cmdsProcessed prometheus.Counter + isRunning bool + + // sessions is a record of websocket connections and their + // respective request contexts + sessions map[string]*bfgWs + + // record the last known canonical chain height, + // if this grows we need to notify subscribers + canonicalChainHeight uint64 +} + +func NewServer(cfg *Config) (*Server, error) { + if cfg == nil { + cfg = NewDefaultConfig() + } + s := &Server{ + cfg: cfg, + popTXFinality: make(map[uint64][]*popTX), + btcHeight: cfg.BTCStartHeight, + server: http.NewServeMux(), + publicServer: http.NewServeMux(), + cmdsProcessed: prometheus.NewCounter(prometheus.CounterOpts{ + Subsystem: promSubsystem, + Name: "rpc_calls_total", + Help: "The total number of succesful RPC commands", + }), + sessions: make(map[string]*bfgWs), + } + + var err error + s.btcClient, err = electrumx.NewClient(cfg.EXBTCAddress) + if err != nil { + return nil, fmt.Errorf("Failed to create electrumx client: %v", err) + } + + // We could use a PGURI verification here. + + return s, nil +} + +func (s *Server) writeResponse(ctx context.Context, conn protocol.APIConn, response any, id string) error { + if err := bfgapi.Write(ctx, conn, id, response); err != nil { + log.Errorf("error occurred writing bfgapi: %s", err) + return err + } + + return nil +} + +func (s *Server) handleBitcoinBalance(ctx context.Context, bws *bfgWs, payload any, id string) (any, error) { + log.Tracef("handleBitcoinBalance") + defer log.Tracef("handleBitcoinBalance exit") + // Increade command count + defer s.cmdsProcessed.Inc() + + br, ok := payload.(*bfgapi.BitcoinBalanceRequest) + if !ok { + return nil, fmt.Errorf("not BitcoinBalanceRequest: %T", br) + } + + bResp := &bfgapi.BitcoinBalanceResponse{} + + balance, err := s.btcClient.Balance(ctx, br.ScriptHash) + if err != nil { + ie := NewInternalErrorf("error getting bitcoin balance: %s", err) + log.Errorf(ie.actual.Error()) + bResp.Error = ie.internal + return bResp, nil + } + bResp.Confirmed = balance.Confirmed + bResp.Unconfirmed = balance.Unconfirmed + + return bResp, nil +} + +func (s *Server) handleBitcoinBroadcast(ctx context.Context, bws *bfgWs, payload any, id string) (any, error) { + log.Tracef("handleBitcoinBroadcast") + defer log.Tracef("handleBitcoinBroadcast exit") + // Increade command count + defer s.cmdsProcessed.Inc() + + bbr, ok := payload.(*bfgapi.BitcoinBroadcastRequest) + if !ok { + return nil, fmt.Errorf("not a BitcoinBroadcastRequest: %T", bbr) + } + + bbResp := &bfgapi.BitcoinBroadcastResponse{} + + rr := bytes.NewReader(bbr.Transaction) + mb := wire.MsgTx{} + if err := mb.Deserialize(rr); err != nil { + bbResp.Error = protocol.Errorf("failed to deserialized tx: %s", err) + return bbResp, nil + } + + var tl2 *pop.TransactionL2 + var err error + for _, v := range mb.TxOut { + tl2, err = pop.ParseTransactionL2FromOpReturn(v.PkScript) + if err != nil { + log.Errorf(err.Error()) // handle real error below + } + } + if tl2 == nil { + bbResp.Error = protocol.Errorf("could not find l2 keystone abbrev in btc tx") + return bbResp, nil + } + + publicKeyUncompressed, err := pop.ParsePublicKeyFromSignatureScript(mb.TxIn[0].SignatureScript) + if err != nil { + bbResp.Error = protocol.Errorf("could not parse signature script: %s", err) + return bbResp, nil + } + + txHash, err := s.btcClient.Broadcast(context.TODO(), bbr.Transaction) + if err != nil { + ie := NewInternalErrorf("error broadcasting to bitcoin: %s", err) + log.Errorf(ie.actual.Error()) + bbResp.Error = ie.internal + return bbResp, nil + } + bbResp.TXID = txHash + + if err := s.db.PopBasisInsertPopMFields(ctx, &bfgd.PopBasis{ + BtcTxId: txHash, + BtcRawTx: database.ByteArray(bbr.Transaction), + PopMinerPublicKey: publicKeyUncompressed, + L2KeystoneAbrevHash: tl2.L2Keystone.Hash(), + }); err != nil { + if errors.Is(err, database.ErrDuplicate) { + bbResp.Error = protocol.Errorf("pop_basis already exists") + return bbResp, nil + } + + if errors.Is(err, database.ErrValidation) { + log.Errorf("invalid pop basis: %s", err) + bbResp.Error = protocol.Errorf("invalid pop_basis") + return bbResp, nil + } + + ie := NewInternalErrorf("error inserting pop basis: %s", err) + bbResp.Error = ie.internal + log.Errorf(ie.actual.Error()) + return bbResp, nil + } + + return bbResp, nil +} + +func (s *Server) handleBitcoinInfo(ctx context.Context, bws *bfgWs, payload any, id string) (any, error) { + log.Tracef("handleBitcoinInfo") + defer log.Tracef("handleBitcoinInfo exit") + // Increade command count + defer s.cmdsProcessed.Inc() + + _, ok := payload.(*bfgapi.BitcoinInfoRequest) + if !ok { + return nil, fmt.Errorf("not a BitcoinInfoRequest %T", payload) + } + + biResp := &bfgapi.BitcoinInfoResponse{} + + height, err := s.btcClient.Height(ctx) + if err != nil { + ie := NewInternalErrorf("error getting bitcoin height: %s", err) + log.Errorf(ie.actual.Error()) + biResp.Error = ie.internal + return biResp, nil + } + biResp.Height = height + + return biResp, nil +} + +func (s *Server) handleBitcoinUTXOs(ctx context.Context, bws *bfgWs, payload any, id string) (any, error) { + log.Tracef("handleBitcoinUTXOs") + defer log.Tracef("handleBitcoinUTXOs exit") + // Increade command count + defer s.cmdsProcessed.Inc() + + bur, ok := payload.(*bfgapi.BitcoinUTXOsRequest) + if !ok { + err := fmt.Errorf("not a BitcoinUTXOsRequest %T", payload) + log.Errorf(err.Error()) + return nil, err + } + + buResp := &bfgapi.BitcoinUTXOsResponse{} + + utxos, err := s.btcClient.UTXOs(context.TODO(), bur.ScriptHash) + if err != nil { + ie := NewInternalErrorf("error getting bitcoin utxos: %s", err) + log.Errorf(ie.actual.Error()) + buResp.Error = ie.internal + return buResp, nil + } + for _, utxo := range utxos { + buResp.UTXOs = append(buResp.UTXOs, &bfgapi.BitcoinUTXO{ + Hash: utxo.Hash, + Index: utxo.Index, + Value: utxo.Value, + }) + } + + return buResp, nil +} + +func (s *Server) handleAccessPublicKeyCreateRequest(ctx context.Context, bws *bfgWs, payload any, id string) (any, error) { + log.Tracef("handleAccessPublicKeyCreateRequest") + defer log.Tracef("handleAccessPublicKeyCreateRequest exit") + + accessPublicKeyCreateRequest, ok := payload.(*bfgapi.AccessPublicKeyCreateRequest) + if !ok { + err := fmt.Errorf("incorrect type: %T", payload) + return nil, err + } + + response := &bfgapi.AccessPublicKeyCreateResponse{} + + publicKey, err := hex.DecodeString(accessPublicKeyCreateRequest.PublicKey) + if err != nil { + response.Error = protocol.Errorf(err.Error()) + return response, nil + } + + if err := s.db.AccessPublicKeyInsert(ctx, &bfgd.AccessPublicKey{ + PublicKey: publicKey, + }); err != nil { + if errors.Is(err, database.ErrDuplicate) { + response.Error = protocol.Errorf("public key already exists") + return response, nil + } + + if errors.Is(err, database.ErrValidation) { + response.Error = protocol.Errorf("invalid access public key") + return response, nil + } + + ie := NewInternalErrorf("error inserting access public key: %s", err) + response.Error = ie.internal + log.Errorf(ie.actual.Error()) + return response, nil + } + + return response, nil +} + +func (s *Server) handleAccessPublicKeyDelete(ctx context.Context, bws *bfgWs, payload any, id string) (any, error) { + log.Tracef("handleAccessPublicKeyDelete") + defer log.Tracef("handleAccessPublicKeyDelete exit") + + accessPublicKeyDeleteRequest, ok := payload.(*bfgapi.AccessPublicKeyDeleteRequest) + if !ok { + return nil, fmt.Errorf("incorrect type %T", payload) + } + + response := &bfgapi.AccessPublicKeyDeleteResponse{} + + b, err := hex.DecodeString(accessPublicKeyDeleteRequest.PublicKey) + if err != nil { + response.Error = protocol.Errorf(err.Error()) + return response, nil + } + + if err := s.db.AccessPublicKeyDelete(ctx, &bfgd.AccessPublicKey{ + PublicKey: b, + }); err != nil { + if errors.Is(err, database.ErrNotFound) { + response.Error = protocol.Errorf("public key not found") + return response, nil + } + ie := NewInternalErrorf("error deleting access public key: %s", err) + response.Error = ie.internal + log.Errorf(ie.actual.Error()) + return response, nil + } + + return response, nil +} + +func (s *Server) processBitcoinBlock(ctx context.Context, height uint64) error { + log.Infof("Processing Bitcoin block at height %d...", height) + + rbh, err := s.btcClient.RawBlockHeader(ctx, height) + if err != nil { + return fmt.Errorf("failed to get block header at height %v: %v", + height, err) + } + + // grab the merkle root from the header, I am not sure if there is a + // better way to do this, I couldn't find one and this works + merkleRoot := bitcoin.MerkleRootFromBlockHeader(rbh) + merkleRootEncoded := hex.EncodeToString(merkleRoot) + + btcHeaderHash := chainhash.DoubleHashB(rbh[:]) + btcHeight := height + btcHeader := rbh + + btcBlock := bfgd.BtcBlock{ + Hash: btcHeaderHash, + Header: btcHeader[:], + Height: btcHeight, + } + + err = s.db.BtcBlockInsert(ctx, &btcBlock) + if err != nil { + // XXX don't return err here so we keep counting up, need to be smarter + if errors.Is(err, database.ErrDuplicate) { + log.Errorf("could not insert btc block: %s", err) + return nil + } + } + + for index := uint64(0); ; index++ { + txHash, merkleHashes, err := s.btcClient.TransactionAtPosition(ctx, + height, index) + if err != nil { + if errors.Is(err, electrumx.ErrNoTxAtPosition) { + // There is no way to tell how many transactions are + // in a block, so hopefully we've got them all... + return nil + } + return fmt.Errorf("failed to get transaction at position (height %v, index %v): %v", height, index, err) + } + + txHashEncoded := hex.EncodeToString(txHash) + log.Tracef("the raw block header is: %v", rbh) + log.Tracef("the txhash is: %v", txHashEncoded) + log.Tracef("the merkle root is: %v", merkleRootEncoded) + log.Tracef("the merkle hashes are: %v", merkleHashes) + log.Tracef("Processing Bitcoin block %d, transaction %d...", + height, index) + log.Tracef("validating bitcoin tx") + + err = bitcoin.ValidateMerkleRoot(txHashEncoded, merkleHashes, + uint32(index), merkleRootEncoded) + if err != nil { + log.Errorf("merkle root validation failed for tx %s: %s", + txHashEncoded, err) + } else { + log.Infof("btc tx is valid with hash %s", txHashEncoded) + } + + rtx, err := s.btcClient.RawTransaction(ctx, txHash) + if err != nil { + return fmt.Errorf("failed to get raw transaction with txid %x: %v", txHash, err) + } + + log.Infof("got raw transaction with txid %x", txHash) + + mtx := &btcwire.MsgTx{} + if err := mtx.Deserialize(bytes.NewReader(rtx)); err != nil { + log.Tracef("Failed to deserialize transaction: %v", err) + continue + } + + var tl2 *pop.TransactionL2 + + for _, txo := range mtx.TxOut { + tl2, err = pop.ParseTransactionL2FromOpReturn(txo.PkScript) + if err == nil { + break + } + } + + if tl2 == nil { + log.Infof("not pop tx found") + continue + } + + btcTxIndex := index + log.Infof("found tl2: %v at position %d", tl2, btcTxIndex) + + publicKeyUncompressed, err := pop.ParsePublicKeyFromSignatureScript(mtx.TxIn[0].SignatureScript) + if err != nil { + return fmt.Errorf("could not parse signature script: %s", err) + } + + popTxIdFull := []byte{} + popTxIdFull = append(popTxIdFull, txHash...) + popTxIdFull = append(popTxIdFull, btcHeader[:]...) + popTxIdFull = binary.AppendUvarint(popTxIdFull, btcTxIndex) // is this correct? + + popTxId := chainhash.DoubleHashB(popTxIdFull) + log.Infof("hashed pop transaction id: %v from %v", popTxId, popTxIdFull) + log.Infof("with merkle hashes %v", merkleHashes) + + popBasis := bfgd.PopBasis{ + BtcTxId: txHash, + BtcHeaderHash: btcHeaderHash, + BtcTxIndex: &btcTxIndex, + PopTxId: popTxId, + L2KeystoneAbrevHash: tl2.L2Keystone.Hash(), + BtcRawTx: rtx, + PopMinerPublicKey: publicKeyUncompressed, + BtcMerklePath: merkleHashes, + } + + // first, try to update a pop_basis row with NULL btc fields + rowsAffected, err := s.db.PopBasisUpdateBTCFields(ctx, &popBasis) + if err != nil { + return err + } + + // if we didn't find any, then we will attempt an insert + if rowsAffected == 0 { + err = s.db.PopBasisInsertFull(ctx, &popBasis) + + // if the insert fails due to a duplicate, this means + // that something else has inserted the row before us + // (i.e. a race condition), this is ok, as it should + // have the same values, so we no-op + if err != nil && errors.Is(database.ErrDuplicate, err) == false { + return err + } + } + + } +} + +func (s *Server) processBitcoinBlocks(ctx context.Context, start, end uint64) error { + for i := start; i <= end; i++ { + if err := s.processBitcoinBlock(ctx, i); err != nil { + return fmt.Errorf("failed to process bitcoin block at height %d: %v", i, err) + } + s.btcHeight = i + } + return nil +} + +func (s *Server) trackBitcoin(ctx context.Context) { + defer s.wg.Done() + + log.Tracef("trackBitcoin") + defer log.Tracef("trackBitcoin exit") + + btcInterval := 5 * time.Second + ticker := time.NewTicker(btcInterval) + printMsg := true + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + log.Tracef("Checking BTC height...") + + btcHeight, err := s.btcClient.Height(ctx) + if err != nil { + if printMsg { + // XXX add this to prometheus + log.Errorf("Failed to get Bitcoin height: %v", err) + printMsg = false + } + continue + } + printMsg = true + if s.btcHeight > btcHeight { + // XXX do we need this check? + log.Errorf("invalid height: current %v > requested %v", + btcHeight, s.btcHeight) + continue + } + if btcHeight <= s.btcHeight { + continue + } + + log.Infof("Bitcoin block height increased to %v", btcHeight) + + if err := s.processBitcoinBlocks(ctx, s.btcHeight+1, btcHeight); err != nil { + log.Errorf("Failed to process Bitcoin blocks: %v", err) + continue + } + } + } +} + +type bfgWs struct { + wg sync.WaitGroup + addr string + conn *protocol.WSConn + sessionId string + requestContext context.Context + notify map[notificationId]struct{} + publicKey []byte +} + +func (s *Server) handleWebsocketPrivateRead(ctx context.Context, bws *bfgWs) { + defer bws.wg.Done() + + log.Tracef("handleWebsocketPrivateRead: %v", bws.addr) + defer log.Tracef("handleWebsocketPrivateRead exit: %v", bws.addr) + + // Command completed + defer s.cmdsProcessed.Inc() + + for { + cmd, id, payload, err := bfgapi.Read(ctx, bws.conn) + if err != nil { + // Don't log normal close errors. + var ce websocket.CloseError + if !errors.As(err, &ce) { + log.Errorf("handleWebsocketPrivateRead: %v", err) + } else { + log.Tracef("handleWebsocketPrivateRead: %v", err) + } + return + } + + // May be too loud. + log.Tracef("handleWebsocketRead read %v: %v %v %v", + bws.addr, cmd, id, spew.Sdump(payload)) + + var response any + + switch cmd { + case bfgapi.CmdPingRequest: + response, err = s.handlePing(ctx, bws, payload, id) + case bfgapi.CmdPopTxForL2BlockRequest: + response, err = s.handlePopTxForL2Block(ctx, bws, payload, id) + case bfgapi.CmdNewL2KeystonesRequest: + response, err = s.handleNewL2Keystones(ctx, bws, payload, id) + case bfgapi.CmdBTCFinalityByRecentKeystonesRequest: + response, err = s.handleBtcFinalityByRecentKeystonesRequest(ctx, bws, payload, id) + case bfgapi.CmdBTCFinalityByKeystonesRequest: + response, err = s.handleBtcFinalityByKeystonesRequest(ctx, bws, payload, id) + case bfgapi.CmdAccessPublicKeyCreateRequest: + response, err = s.handleAccessPublicKeyCreateRequest(ctx, bws, payload, id) + case bfgapi.CmdAccessPublicKeyDeleteRequest: + response, err = s.handleAccessPublicKeyDelete(ctx, bws, payload, id) + default: + err = fmt.Errorf("unknown command") + } + + // if there was an error, close the websocket, only do this if we + // can't continue + if err != nil { + log.Errorf("handleWebsocketPrivateRead error %v %v: %v", bws.addr, cmd, err) + bws.conn.CloseStatus(websocket.StatusProtocolError, + err.Error()) + return + } else { + if err := s.writeResponse(ctx, bws.conn, response, id); err != nil { + bws.conn.CloseStatus(websocket.StatusProtocolError, err.Error()) + return + } + } + + } +} + +func (s *Server) handleWebsocketPublicRead(ctx context.Context, bws *bfgWs) { + defer bws.wg.Done() + + log.Tracef("handleWebsocketPublicRead: %v", bws.addr) + defer log.Tracef("handleWebsocketPublicRead exit: %v", bws.addr) + + // Command completed + defer s.cmdsProcessed.Inc() + + for { + cmd, id, payload, err := bfgapi.Read(ctx, bws.conn) + if err != nil { + // Don't log normal close errors. + var ce websocket.CloseError + if !errors.As(err, &ce) { + log.Debugf("handleWebsocketPublicRead: %v", err) + } else { + log.Tracef("handleWebsocketPublicRead: %v", err) + } + return + } + + var response any + + switch cmd { + case bfgapi.CmdPingRequest: + response, err = s.handlePing(ctx, bws, payload, id) + case bfgapi.CmdL2KeystonesRequest: + response, err = s.handleL2KeystonesRequest(ctx, bws, payload, id) + case bfgapi.CmdBitcoinBalanceRequest: + response, err = s.handleBitcoinBalance(ctx, bws, payload, id) + case bfgapi.CmdBitcoinBroadcastRequest: + response, err = s.handleBitcoinBroadcast(ctx, bws, payload, id) + case bfgapi.CmdBitcoinInfoRequest: + response, err = s.handleBitcoinInfo(ctx, bws, payload, id) + case bfgapi.CmdBitcoinUTXOsRequest: + response, err = s.handleBitcoinUTXOs(ctx, bws, payload, id) + default: + err = fmt.Errorf("unknown command") + } + + if err != nil { + log.Errorf("handleWebsocketPublicRead %v %v: %v", bws.addr, cmd, err) + bws.conn.CloseStatus(websocket.StatusProtocolError, + err.Error()) + return + } else { + if err := s.writeResponse(ctx, bws.conn, response, id); err != nil { + bws.conn.CloseStatus(websocket.StatusProtocolError, err.Error()) + return + } + } + + } +} + +func (s *Server) newSession(bws *bfgWs) (string, error) { + b := make([]byte, 16) + + for { + // create random value and encode to string + _, err := rand.Read(b) + if err != nil { + return "", err + } + id := hex.EncodeToString(b) + + // does this random value exist? if so try again + s.mtx.Lock() + if _, ok := s.sessions[id]; ok { + s.mtx.Unlock() + continue + } + s.sessions[id] = bws + s.mtx.Unlock() + + return id, nil + } +} + +func (s *Server) killSession(id string, why websocket.StatusCode) { + s.mtx.Lock() + bws, ok := s.sessions[id] + if ok { + delete(s.sessions, id) + } + s.mtx.Unlock() + + if !ok { + log.Errorf("killSession: id not found in sessions %s", id) + } else { + if err := bws.conn.CloseStatus(why, ""); err != nil { + // XXX this is too noisy. + log.Debugf("session close %v: %v", id, err) + } + } +} + +func (s *Server) handleWebsocketPrivate(w http.ResponseWriter, r *http.Request) { + log.Tracef("handleWebsocketPrivate: %v", r.RemoteAddr) + defer log.Tracef("handleWebsocketPrivate exit: %v", r.RemoteAddr) + + wao := &websocket.AcceptOptions{ + CompressionMode: websocket.CompressionContextTakeover, + OriginPatterns: []string{"localhost"}, + } + + conn, err := websocket.Accept(w, r, wao) + if err != nil { + log.Errorf("Failed to accept websocket connection for %v: %v", + r.RemoteAddr, err) + return + } + + bws := &bfgWs{ + addr: r.RemoteAddr, + conn: protocol.NewWSConn(conn), + notify: map[notificationId]struct{}{ + notifyBtcBlocks: {}, + notifyBtcFinalities: {}, + }, + requestContext: r.Context(), + } + + if bws.sessionId, err = s.newSession(bws); err != nil { + log.Errorf("error occurred creating key: %s", err) + return + } + + defer func() { + s.killSession(bws.sessionId, websocket.StatusNormalClosure) + }() + + bws.wg.Add(1) + go s.handleWebsocketPrivateRead(r.Context(), bws) + + // Always ping, required by protocol. + ping := &bfgapi.PingRequest{ + Timestamp: time.Now().Unix(), + } + + log.Tracef("responding with %s", spew.Sdump(ping)) + if err := bfgapi.Write(r.Context(), bws.conn, "0", ping); err != nil { + log.Errorf("Write: %v", err) + } + + log.Infof("Unauthenticated connection from %v", r.RemoteAddr) + bws.wg.Wait() + log.Infof("Unauthenticated connection terminated from %v", r.RemoteAddr) +} + +func (s *Server) handleWebsocketPublic(w http.ResponseWriter, r *http.Request) { + log.Tracef("handleWebsocketPublic: %v", r.RemoteAddr) + defer log.Tracef("handleWebsocketPublic exit: %v", r.RemoteAddr) + + wao := &websocket.AcceptOptions{ + CompressionMode: websocket.CompressionContextTakeover, + OriginPatterns: []string{"localhost:43111"}, + InsecureSkipVerify: true, // XXX sucks but we don't want to whitelist every locahost port + } + + conn, err := websocket.Accept(w, r, wao) + if err != nil { + log.Errorf("Failed to accept websocket connection for %v: %v", + r.RemoteAddr, err) + return + } + defer conn.Close(websocket.StatusNormalClosure, "") + + bws := &bfgWs{ + addr: r.RemoteAddr, + conn: protocol.NewWSConn(conn), + requestContext: r.Context(), + notify: map[notificationId]struct{}{ + notifyL2Keystones: {}, + }, + } + + // Must complete handshake in WSHandshakeTimeout. + hsCtx, hsCancel := context.WithTimeout(context.Background(), + protocol.WSHandshakeTimeout) + defer hsCancel() + + authenticator, err := auth.NewSecp256k1AuthServer() + if err != nil { + log.Errorf("Handshake failed for %v: %s", bws.addr, err) + return + } + if err := authenticator.HandshakeServer(hsCtx, bws.conn); err != nil { + log.Errorf("Handshake Server failed for %v: %s", bws.addr, err) + return + } + publicKey := authenticator.RemotePublicKey().SerializeCompressed() + publicKeyEncoded := hex.EncodeToString(publicKey) + log.Tracef("successful handshake with public key: %s", publicKeyEncoded) + + if s.cfg.PublicKeyAuth { + log.Tracef("will enforce auth") + + // XXX this code should be a function that returns just true + // and false; that function logs errors. + exists, err := s.db.AccessPublicKeyExists(hsCtx, &bfgd.AccessPublicKey{ + PublicKey: publicKey, + }) + if err != nil { + log.Errorf("error occurred checking if public key exists: %s", err) + return + } + if !exists { + log.Errorf("unauthorized public key: %s", publicKeyEncoded) + conn.Close(protocol.PublicKeyAuthError.Code, protocol.PublicKeyAuthError.Reason) + return + } + } + + bws.publicKey = publicKey + if bws.sessionId, err = s.newSession(bws); err != nil { + log.Errorf("error occurred creating key: %s", err) + return + } + defer func() { + s.killSession(bws.sessionId, websocket.StatusNormalClosure) + }() + + // Always ping, required by protocol. + ping := &bfgapi.PingRequest{ + Timestamp: time.Now().Unix(), + } + + if err := bfgapi.Write(r.Context(), bws.conn, "0", ping); err != nil { + log.Errorf("Write: %v", err) + } + + bws.wg.Add(1) + go s.handleWebsocketPublicRead(r.Context(), bws) + + log.Infof("Authenticated session %s from %s public key %x", + bws.sessionId, r.RemoteAddr, bws.publicKey) + bws.wg.Wait() + log.Infof("Terminated session %s from %s public key %x", + bws.sessionId, r.RemoteAddr, bws.publicKey) +} + +func (s *Server) handlePing(ctx context.Context, bws *bfgWs, payload any, id string) (any, error) { + log.Tracef("handlePing: %v", bws.addr) + defer log.Tracef("handlePing exit: %v", bws.addr) + + p, ok := payload.(*bfgapi.PingRequest) + if !ok { + return nil, fmt.Errorf("handlePing invalid payload type: %T", payload) + } + response := &bfgapi.PingResponse{ + OriginTimestamp: p.Timestamp, + Timestamp: time.Now().Unix(), + } + + return response, nil +} + +func (s *Server) handlePopTxForL2Block(ctx context.Context, bws *bfgWs, payload any, id string) (any, error) { + log.Tracef("handlePopTxForL2Block: %v", bws.addr) + defer log.Tracef("handlePopTxForL2Block exit: %v", bws.addr) + + p, ok := payload.(*bfgapi.PopTxsForL2BlockRequest) + if !ok { + return nil, fmt.Errorf("handlePopTxForL2Block invalid payload type: %T", + payload) + } + + response := bfgapi.PopTxsForL2BlockResponse{} + + hash := hemi.HashSerializedL2KeystoneAbrev(p.L2Block) + var h [32]byte + copy(h[:], hash) + popTxs, err := s.db.PopBasisByL2KeystoneAbrevHash(ctx, h, true) + if err != nil { + ie := NewInternalErrorf("error getting pop basis: %s", err) + response.Error = ie.internal + log.Errorf(ie.actual.Error()) + return response, nil + } + + response.PopTxs = make([]bfgapi.PopTx, 0, len(popTxs)) + + for k := range popTxs { + response.PopTxs = append(response.PopTxs, bfgapi.PopTx{ + BtcTxId: api.ByteSlice(popTxs[k].BtcTxId), + BtcRawTx: api.ByteSlice(popTxs[k].BtcRawTx), + BtcHeaderHash: api.ByteSlice(popTxs[k].BtcHeaderHash), + BtcTxIndex: popTxs[k].BtcTxIndex, + BtcMerklePath: popTxs[k].BtcMerklePath, + PopTxId: api.ByteSlice(popTxs[k].PopTxId), + PopMinerPublicKey: api.ByteSlice(popTxs[k].PopMinerPublicKey), + L2KeystoneAbrevHash: api.ByteSlice(popTxs[k].L2KeystoneAbrevHash), + }) + } + + return response, nil +} + +func (s *Server) handleBtcFinalityByRecentKeystonesRequest(ctx context.Context, bws *bfgWs, payload any, id string) (any, error) { + p, ok := payload.(*bfgapi.BTCFinalityByRecentKeystonesRequest) + if ok == false { + return nil, fmt.Errorf( + "handleBtcFinalityByRecentKeystonesRequest invalid payload type %T", + payload, + ) + } + + response := bfgapi.BTCFinalityByRecentKeystonesResponse{} + + finalities, err := s.db.L2BTCFinalityMostRecent(ctx, p.NumRecentKeystones) + if err != nil { + ie := NewInternalErrorf("error getting finality: %s", err) + response.Error = ie.internal + log.Errorf(ie.actual.Error()) + return response, nil + } + + apiFinalities := []hemi.L2BTCFinality{} + for _, finality := range finalities { + apiFinality, err := hemi.L2BTCFinalityFromBfgd( + &finality, + finality.BTCTipHeight, + finality.EffectiveHeight, + ) + if err != nil { + return nil, err + } + apiFinalities = append(apiFinalities, *apiFinality) + } + + response.L2BTCFinalities = apiFinalities + + return response, nil +} + +func (s *Server) handleBtcFinalityByKeystonesRequest(ctx context.Context, bws *bfgWs, payload any, id string) (any, error) { + p, ok := payload.(*bfgapi.BTCFinalityByKeystonesRequest) + if ok == false { + return nil, fmt.Errorf( + "handleBtcFinalityByKeystonesRequest invalid payload type %T", + payload, + ) + } + + response := bfgapi.BTCFinalityByKeystonesResponse{} + + l2KeystoneAbrevHashes := []database.ByteArray{} + + for _, l := range p.L2Keystones { + a := hemi.L2KeystoneAbbreviate(l) + l2KeystoneAbrevHashes = append(l2KeystoneAbrevHashes, a.Hash()) + } + + finalities, err := s.db.L2BTCFinalityByL2KeystoneAbrevHash( + ctx, + l2KeystoneAbrevHashes, + ) + if err != nil { + ie := NewInternalErrorf("error getting l2 keystones: %s", err) + response.Error = ie.internal + log.Errorf(ie.actual.Error()) + return response, nil + } + + apiFinalities := []hemi.L2BTCFinality{} + for _, finality := range finalities { + apiFinality, err := hemi.L2BTCFinalityFromBfgd( + &finality, + finality.BTCTipHeight, + finality.EffectiveHeight, + ) + if err != nil { + return nil, err + } + apiFinalities = append(apiFinalities, *apiFinality) + } + + response.L2BTCFinalities = apiFinalities + + return response, nil +} + +func (s *Server) handleL2KeystonesRequest(ctx context.Context, bws *bfgWs, payload any, id string) (any, error) { + p, ok := payload.(*bfgapi.L2KeystonesRequest) + if ok == false { + return nil, fmt.Errorf( + "handleL2KeystonesRequest invalid payload type %T", + payload, + ) + } + + gkhResp := &bfgapi.L2KeystonesResponse{} + + results, err := s.db.L2KeystonesMostRecentN(ctx, + uint32(p.NumL2Keystones)) + if err != nil { + ie := NewInternalErrorf("error getting l2 keystones: %s", err) + gkhResp.Error = ie.internal + log.Errorf(ie.actual.Error()) + return gkhResp, nil + } + + for _, v := range results { + gkhResp.L2Keystones = append(gkhResp.L2Keystones, hemi.L2Keystone{ + Version: uint8(v.Version), + L1BlockNumber: v.L1BlockNumber, + L2BlockNumber: v.L2BlockNumber, + ParentEPHash: api.ByteSlice(v.ParentEPHash), + PrevKeystoneEPHash: api.ByteSlice(v.PrevKeystoneEPHash), + StateRoot: api.ByteSlice(v.StateRoot), + EPHash: api.ByteSlice(v.EPHash), + }) + } + + return gkhResp, nil +} + +func writeNotificationResponse(bws *bfgWs, response any) { + if err := bfgapi.Write(bws.requestContext, bws.conn, "", response); err != nil { + log.Errorf( + "handleBtcFinalityNotification write: %v %v", + bws.addr, + err, + ) + } +} + +func (s *Server) handleBtcFinalityNotification() error { + response := bfgapi.BTCFinalityNotification{} + + s.mtx.Lock() + for _, bws := range s.sessions { + if _, ok := bws.notify[notifyBtcFinalities]; !ok { + continue + } + go writeNotificationResponse(bws, response) + } + s.mtx.Unlock() + + return nil +} + +func (s *Server) handleBtcBlockNotification() error { + response := bfgapi.BTCNewBlockNotification{} + + s.mtx.Lock() + for _, bws := range s.sessions { + if _, ok := bws.notify[notifyBtcBlocks]; !ok { + continue + } + go writeNotificationResponse(bws, response) + } + s.mtx.Unlock() + + return nil +} + +func (s *Server) handleL2KeystonesNotification() error { + response := bfgapi.L2KeystonesNotification{} + + s.mtx.Lock() + for _, bws := range s.sessions { + if _, ok := bws.notify[notifyL2Keystones]; !ok { + continue + } + go writeNotificationResponse(bws, response) + } + s.mtx.Unlock() + + return nil +} + +func hemiL2KeystoneToDb(l2ks hemi.L2Keystone) bfgd.L2Keystone { + return bfgd.L2Keystone{ + Hash: hemi.L2KeystoneAbbreviate(l2ks).Hash(), + Version: uint32(l2ks.Version), + L1BlockNumber: l2ks.L1BlockNumber, + L2BlockNumber: l2ks.L2BlockNumber, + ParentEPHash: database.ByteArray(l2ks.ParentEPHash), + PrevKeystoneEPHash: database.ByteArray(l2ks.PrevKeystoneEPHash), + StateRoot: database.ByteArray(l2ks.StateRoot), + EPHash: database.ByteArray(l2ks.EPHash), + } +} + +func hemiL2KeystonesToDb(l2ks []hemi.L2Keystone) []bfgd.L2Keystone { + dbks := make([]bfgd.L2Keystone, 0, len(l2ks)) + for k := range l2ks { + dbks = append(dbks, hemiL2KeystoneToDb(l2ks[k])) + } + return dbks +} + +func (s *Server) handleNewL2Keystones(ctx context.Context, bws *bfgWs, payload any, id string) (any, error) { + ks := hemiL2KeystonesToDb(payload.(*bfgapi.NewL2KeystonesRequest).L2Keystones) + err := s.db.L2KeystonesInsert(ctx, ks) + response := bfgapi.NewL2KeystonesResponse{} + if err != nil { + if errors.Is(err, database.ErrDuplicate) { + response.Error = protocol.Errorf("l2 keystone already exists") + return response, nil + } + if errors.Is(err, database.ErrValidation) { + log.Errorf("error inserting l2 keystone: %s", err) + response.Error = protocol.Errorf("invalid l2 keystone") + return response, nil + } + + return nil, err + } + + return response, nil +} + +func (s *Server) running() bool { + s.mtx.RLock() + defer s.mtx.RUnlock() + return s.isRunning +} + +func (s *Server) testAndSetRunning(b bool) bool { + s.mtx.Lock() + defer s.mtx.Unlock() + old := s.isRunning + s.isRunning = b + return old != s.isRunning +} + +func (s *Server) promRunning() float64 { + r := s.running() + if r { + return 1 + } + return 0 +} + +func handle(service string, mux *http.ServeMux, pattern string, handler func(http.ResponseWriter, *http.Request)) { + mux.HandleFunc(pattern, handler) + log.Infof("handle (%v): %v", service, pattern) +} + +func (s *Server) handleStateUpdates(table string, action string, payload, payloadOld interface{}) { + ctx := context.Background() + + // get the last known canonical chain height + s.mtx.RLock() + heightBefore := s.canonicalChainHeight + s.mtx.RUnlock() + + // get the current canoncial chain height from the db + heightAfter, err := s.db.BtcBlockCanonicalHeight(ctx) + if err != nil { + log.Errorf("error occurred getting canonical height: %s", err) + } + + // the canonical chain grew from the last insert, then we assume there is a + // new block on the canonical chain, and finalities of existing blocks + // will change + if heightAfter > heightBefore { + go s.handleBtcFinalityNotification() + go s.handleBtcBlockNotification() + } + + s.mtx.Lock() + s.canonicalChainHeight = heightAfter + s.mtx.Unlock() +} + +func (s *Server) handleAccessPublicKeys(table string, action string, payload, payloadOld interface{}) { + log.Tracef("received payloads: %v, %v", payload, payloadOld) + + if action != "DELETE" { + return + } + + accessPublicKey, ok := payloadOld.(*bfgd.AccessPublicKey) + if !ok { + log.Errorf("incorrect type %T", payload) + } + + if accessPublicKey == nil { + return + } + + // XXX this is racing with killSession but protected. We should + // create a killSessions that takes an encoded PublicKey + s.mtx.Lock() + for _, v := range s.sessions { + // if public key does not exist on session, it's not an authenticated + // session so we don't close it because it didn't use a public key + if v.publicKey == nil || len(v.publicKey) == 0 { + continue + } + + // the database value will be passed with \x prefixed to denote hex + // encoding, ensure that the session string does for an equal comparison + sessionPublicKeyEncoded := fmt.Sprintf("\\x%s", hex.EncodeToString(v.publicKey)) + if sessionPublicKeyEncoded == accessPublicKey.PublicKeyEncoded { + sessionId := v.sessionId + go s.killSession(sessionId, protocol.StatusHandshakeErr) + } + } + s.mtx.Unlock() +} + +func (s *Server) handleL2KeystonesChange(table string, action string, payload, payloadOld any) { + go s.handleL2KeystonesNotification() +} + +func (s *Server) Run(pctx context.Context) error { + log.Tracef("Run") + defer log.Tracef("Run exit") + + if !s.testAndSetRunning(true) { + return fmt.Errorf("bfg already running") + } + defer s.testAndSetRunning(false) + + // XXX this funciton seems a bit heavy. Trim it by moving functionality to functions. + ctx, cancel := context.WithCancel(pctx) + defer cancel() + + // Connect to db. + // XXX should we reconnect? + var err error + s.db, err = postgres.New(ctx, s.cfg.PgURI) + if err != nil { + return fmt.Errorf("Failed to connect to database: %v", err) + } + defer s.db.Close() + + if s.btcHeight, err = s.db.BtcBlockCanonicalHeight(ctx); err != nil { + return err + } + + // if there is no height in the db, check the config + if s.btcHeight == 0 { + s.btcHeight = s.cfg.BTCStartHeight + log.Infof("received height of 0 from the db, height of %v from config", + s.cfg.BTCStartHeight) + } + + // if the config doesn't set a height, error + if s.btcHeight == 0 { + return errors.New("could not determine btc start height") + } + log.Debugf("resuming at height %d", s.btcHeight) + + // Database notifications + btcBlocksPayload, ok := bfgd.NotificationPayload(bfgd.NotificationBtcBlocks) + if !ok { + return fmt.Errorf("could not obtain type: %v", bfgd.NotificationBtcBlocks) + } + // XXX rename handler function and don't be generic + if err := s.db.RegisterNotification(ctx, bfgd.NotificationBtcBlocks, + s.handleStateUpdates, btcBlocksPayload); err != nil { + return err + } + + accessPublicKeysPayload, ok := bfgd.NotificationPayload(bfgd.NotificationAccessPublicKeyDelete) + if !ok { + return fmt.Errorf("could not obtain type: %v", bfgd.NotificationAccessPublicKeyDelete) + } + if err := s.db.RegisterNotification(ctx, bfgd.NotificationAccessPublicKeyDelete, + s.handleAccessPublicKeys, accessPublicKeysPayload); err != nil { + return err + } + + l2KeystonesPayload, ok := bfgd.NotificationPayload(bfgd.NotificationL2Keystones) + if !ok { + return fmt.Errorf("could not obtain type: %v", bfgd.NotificationL2Keystones) + } + if err := s.db.RegisterNotification(ctx, bfgd.NotificationL2Keystones, + s.handleL2KeystonesChange, l2KeystonesPayload); err != nil { + return err + } + + // Setup websockets and HTTP routes + privateMux := s.server + publicMux := s.publicServer + + handle("bfgpriv", privateMux, bfgapi.RouteWebsocketPrivate, s.handleWebsocketPrivate) + handle("bfgpub", publicMux, bfgapi.RouteWebsocketPublic, s.handleWebsocketPublic) + + publicHttpServer := &http.Server{ + Addr: s.cfg.PublicListenAddress, + Handler: publicMux, + BaseContext: func(net.Listener) context.Context { return ctx }, + } + publicHttpErrCh := make(chan error) + + privateHttpServer := &http.Server{ + Addr: s.cfg.PrivateListenAddress, + Handler: privateMux, + BaseContext: func(net.Listener) context.Context { return ctx }, + } + privateHttpErrCh := make(chan error) + + go func() { + log.Infof("Listening: %v", s.cfg.PrivateListenAddress) + privateHttpErrCh <- privateHttpServer.ListenAndServe() + }() + + go func() { + log.Infof("Listening: %v", s.cfg.PublicListenAddress) + publicHttpErrCh <- publicHttpServer.ListenAndServe() + }() + + defer func() { + if err := privateHttpServer.Shutdown(ctx); err != nil { + log.Errorf("http private Server exit: %v", err) + return + } + log.Infof("private RPC Server shutdown cleanly") + }() + + defer func() { + if err := publicHttpServer.Shutdown(ctx); err != nil { + log.Errorf("http public Server exit: %v", err) + return + } + log.Infof("public RPC Server shutdown cleanly") + }() + + // Prometheus + if s.cfg.PrometheusListenAddress != "" { + d, err := deucalion.New(&deucalion.Config{ + ListenAddress: s.cfg.PrometheusListenAddress, + }) + if err != nil { + return fmt.Errorf("failed to create server: %w", err) + } + cs := []prometheus.Collector{ + s.cmdsProcessed, + prometheus.NewGaugeFunc(prometheus.GaugeOpts{ + Subsystem: promSubsystem, + Name: "running", + Help: "Is bfg service running.", + }, s.promRunning), + } + s.wg.Add(1) + go func() { + defer s.wg.Done() + if err := d.Run(ctx, cs); err != context.Canceled { + log.Errorf("prometheus terminated with error: %v", err) + return + } + log.Infof("prometheus clean shutdown") + }() + } + + s.wg.Add(1) + go s.trackBitcoin(ctx) + + select { + case <-ctx.Done(): + err = ctx.Err() + case err = <-privateHttpErrCh: + case err = <-publicHttpErrCh: + } + cancel() + + log.Infof("bfg service shutting down") + s.wg.Wait() + log.Infof("bfg service clean shutdown") + + return err +} diff --git a/service/bfg/bfg_test.go b/service/bfg/bfg_test.go new file mode 100644 index 00000000..baf69cf2 --- /dev/null +++ b/service/bfg/bfg_test.go @@ -0,0 +1,172 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package bfg + +import ( + "bytes" + "fmt" + "testing" + + btcchainhash "github.com/btcsuite/btcd/chaincfg/chainhash" + btcwire "github.com/btcsuite/btcd/wire" + + "github.com/hemilabs/heminetwork/api" + "github.com/hemilabs/heminetwork/bitcoin" + "github.com/hemilabs/heminetwork/hemi" +) + +// BitcoinFinality used to be in production code, it has been removed but +// we want to keep tests around its structure, so define it here +type BitcoinFinality struct { + HEMIHeader *hemi.Header `json:"hemi_header"` + BTCFinalityHeight uint64 `json:"btc_finality_height"` + BTCHeight uint64 `json:"btc_height"` + BTCRawBlockHeader api.ByteSlice `json:"btc_raw_block_header"` + BTCRawTransaction api.ByteSlice `json:"btc_raw_transaction"` + BTCTransactionIndex uint32 `json:"btc_transaction_index"` + BTCMerkleHashes []api.ByteSlice `json:"btc_merkle_hashes"` + POPMinerPublicKey api.ByteSlice `json:"pop_miner_public_key"` +} + +func checkBitcoinFinality(bf *BitcoinFinality) error { + // Parse BTC block header and transaction. + btcHeader := &btcwire.BlockHeader{} + if err := btcHeader.Deserialize(bytes.NewReader(bf.BTCRawBlockHeader)); err != nil { + return fmt.Errorf("failed to deserialize BTC header: %v", err) + } + btcTransaction := &btcwire.MsgTx{} + if err := btcTransaction.Deserialize(bytes.NewReader(bf.BTCRawTransaction)); err != nil { + return fmt.Errorf("failed to deserialize BTC transaction: %v", err) + } + btcTxHash := btcchainhash.DoubleHashB(bf.BTCRawTransaction) + + // Verify transaction to block header. + var merkleHashes [][]byte + for _, merkleHash := range bf.BTCMerkleHashes { + merkleHashes = append(merkleHashes, merkleHash) + } + if err := bitcoin.CheckMerkleChain(btcTxHash, bf.BTCTransactionIndex, merkleHashes, btcHeader.MerkleRoot[:]); err != nil { + return fmt.Errorf("failed to verify merkle path for transaction: %v", err) + } + + // XXX - verify HEMI keystone header and PoP miner public key. + + return nil +} + +func testBitcoinFinality() *BitcoinFinality { + return &BitcoinFinality{ + BTCHeight: 2530685, + BTCMerkleHashes: []api.ByteSlice{ + []byte{ + 0x69, 0x9d, 0x14, 0xb7, 0xbb, 0xe6, 0x87, 0x7a, + 0x6c, 0x30, 0x1e, 0xdd, 0x60, 0xa5, 0x0d, 0x63, + 0x6c, 0xae, 0xe4, 0xdb, 0x4a, 0xce, 0x82, 0xbf, + 0x62, 0xc0, 0xc8, 0xf1, 0xdd, 0x89, 0x98, 0xa3, + }, + []byte{ + 0x18, 0xf5, 0xbd, 0x44, 0xf1, 0xea, 0x94, 0x43, + 0x7e, 0x15, 0xe7, 0xa3, 0x98, 0xd2, 0x5e, 0xb0, + 0x68, 0x0a, 0x0b, 0xdd, 0xf5, 0x08, 0xd7, 0xbb, + 0xc8, 0xa0, 0x90, 0x35, 0x3e, 0x3a, 0x28, 0x1e, + }, + []byte{ + 0xa5, 0xee, 0xe1, 0x40, 0x78, 0x15, 0xca, 0x16, + 0xa8, 0x95, 0xab, 0x3a, 0xe9, 0xe6, 0xa6, 0x85, + 0x81, 0x79, 0x90, 0x10, 0xfd, 0x99, 0x89, 0x29, + 0x0b, 0xdf, 0xbe, 0xf0, 0xf6, 0x0a, 0x97, 0x57, + }, + []byte{ + 0x94, 0xd0, 0xb0, 0x0a, 0x81, 0x59, 0x3e, 0xc3, + 0xfe, 0xb8, 0xba, 0x26, 0xf4, 0x0b, 0x9e, 0x6d, + 0x1a, 0x90, 0xdc, 0xac, 0x8e, 0x8d, 0xdc, 0x97, + 0xf4, 0x7e, 0xff, 0xcb, 0x4d, 0xb7, 0x5c, 0xb7, + }, + []byte{ + 0x21, 0x9d, 0xa2, 0xe3, 0x06, 0x0d, 0x64, 0xfe, + 0x95, 0xe5, 0x24, 0xc6, 0x39, 0x4f, 0x21, 0xd2, + 0xa1, 0x78, 0x30, 0x34, 0x23, 0xbc, 0x8a, 0x74, + 0xa2, 0xf7, 0x71, 0x1d, 0x9f, 0xb1, 0x0f, 0x58, + }, + }, + BTCRawBlockHeader: []byte{ + 0x00, 0x00, 0xc0, 0x20, 0x3c, 0x43, 0x87, 0x05, + 0xaf, 0x3b, 0x7f, 0x28, 0x4f, 0x8b, 0x79, 0xf3, + 0xf4, 0x94, 0xa0, 0x8f, 0x75, 0x70, 0x32, 0x58, + 0x69, 0x2d, 0x58, 0xe5, 0x03, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x6a, 0x00, 0xac, 0xda, + 0xdd, 0x1b, 0xff, 0xb8, 0xe5, 0xb8, 0x05, 0x41, + 0x33, 0xbd, 0x52, 0xa2, 0x25, 0xfb, 0x3f, 0x9e, + 0xcb, 0x0f, 0x41, 0x11, 0xd5, 0x21, 0xdc, 0xef, + 0xd3, 0x5d, 0xe8, 0xf6, 0xc5, 0x36, 0x20, 0x65, + 0x8c, 0xec, 0x00, 0x1a, 0x02, 0xb2, 0x9c, 0xab, + }, + BTCRawTransaction: []byte{ + 0x02, 0x00, 0x00, 0x00, 0x01, 0x2e, 0x2c, 0x5c, + 0xd1, 0x3e, 0x0f, 0x26, 0x04, 0xc9, 0x67, 0x90, + 0xaa, 0xc2, 0x04, 0x42, 0xc4, 0xac, 0x71, 0x8e, + 0xfc, 0x9e, 0x9c, 0xe7, 0xb7, 0x37, 0xfc, 0xfe, + 0x4c, 0x4f, 0xd6, 0x3f, 0x09, 0x01, 0x00, 0x00, + 0x00, 0x6a, 0x47, 0x30, 0x44, 0x02, 0x20, 0x7c, + 0x88, 0x78, 0xcf, 0x03, 0x66, 0x64, 0xdf, 0xbf, + 0x63, 0x8e, 0xd0, 0x76, 0x0a, 0x1d, 0x00, 0x18, + 0xe3, 0xd1, 0xba, 0xe6, 0xee, 0xeb, 0x1f, 0x41, + 0xed, 0x77, 0x72, 0x57, 0xfa, 0xbd, 0x1b, 0x02, + 0x20, 0x4d, 0x2e, 0x48, 0x43, 0x11, 0x48, 0x68, + 0xe9, 0x15, 0x5c, 0x96, 0xdc, 0xe0, 0xed, 0x10, + 0x2c, 0xd2, 0xb6, 0x79, 0x5c, 0xac, 0x5e, 0x91, + 0x2a, 0xc6, 0x25, 0xb1, 0x51, 0xbf, 0x67, 0x9d, + 0x11, 0x01, 0x21, 0x03, 0x9d, 0x3b, 0x17, 0x47, + 0x09, 0x36, 0x48, 0xca, 0x02, 0x13, 0xd3, 0xea, + 0x41, 0x8d, 0x7e, 0x1a, 0x5e, 0x37, 0xb7, 0x98, + 0xf6, 0xf6, 0xdf, 0x4f, 0xc9, 0xa1, 0x7a, 0x6e, + 0x90, 0xde, 0xaa, 0x12, 0xff, 0xff, 0xff, 0xff, + 0x02, 0xbf, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x19, 0x76, 0xa9, 0x14, 0xdc, 0x11, 0xbb, + 0xaf, 0xe2, 0x3f, 0xdd, 0xca, 0x0e, 0xaf, 0xd5, + 0xf7, 0xb3, 0x2c, 0x9d, 0x67, 0x54, 0xca, 0x73, + 0x4e, 0x88, 0xac, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x46, 0x6a, 0x44, 0x78, 0x79, + 0x7a, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x15, 0xfe, 0x01, 0xd8, + 0x46, 0xd9, 0xf5, 0x15, 0x0f, 0x40, 0x7e, 0x86, + 0xc1, 0x1f, 0x80, 0xc2, 0x69, 0x5f, 0x93, 0xd3, + 0x2e, 0x3b, 0x08, 0x3c, 0x4b, 0xf9, 0xdf, 0x97, + 0xf3, 0xa2, 0xa2, 0xa2, 0x5a, 0x73, 0x13, 0x75, + 0xcc, 0xb9, 0x7c, 0x9d, 0x26, 0x00, + }, + BTCTransactionIndex: 19, + } +} + +func TestCheckBitcoinFinality(t *testing.T) { + bf := testBitcoinFinality() + if err := checkBitcoinFinality(bf); err != nil { + t.Errorf("Bitcoin finality check failed: %v", err) + } + + // Truncate raw bitcoin header. + bf = testBitcoinFinality() + bf.BTCRawBlockHeader = bf.BTCRawBlockHeader[:len(bf.BTCRawBlockHeader)-1] + if err := checkBitcoinFinality(bf); err == nil { + t.Error("Bitcoin finality succeeded, should have failed") + } + + // Change TX hash, causing the merkle chain verification to fail. + bf = testBitcoinFinality() + bf.BTCRawTransaction[0] = 0x03 + if err := checkBitcoinFinality(bf); err == nil { + t.Error("Bitcoin finality succeeded, should have failed") + } + + // Change the transaction index, causing the merkle chain verification to fail. + bf = testBitcoinFinality() + bf.BTCTransactionIndex = 20 + if err := checkBitcoinFinality(bf); err == nil { + t.Error("Bitcoin finality succeeded, should have failed") + } +} diff --git a/service/bss/bss.go b/service/bss/bss.go new file mode 100644 index 00000000..002c2c2c --- /dev/null +++ b/service/bss/bss.go @@ -0,0 +1,846 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package bss + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "math/big" + "net" + "net/http" + "sync" + "time" + + "github.com/davecgh/go-spew/spew" + "github.com/juju/loggo" + "github.com/prometheus/client_golang/prometheus" + "nhooyr.io/websocket" + + "github.com/hemilabs/heminetwork/api/bfgapi" + "github.com/hemilabs/heminetwork/api/bssapi" + "github.com/hemilabs/heminetwork/api/protocol" + "github.com/hemilabs/heminetwork/ethereum" + "github.com/hemilabs/heminetwork/hemi" + "github.com/hemilabs/heminetwork/service/deucalion" +) + +const ( + logLevel = "bss=INFO" + verbose = false + + promSubsystem = "bss_service" // Prometheus + +) + +var log = loggo.GetLogger("bss") + +func init() { + loggo.ConfigureLoggers(logLevel) +} + +// InternalError is an error type to differentiates between caller and callee +// errors. An internal error is used whne something internal to the application +// fails. +type InternalError struct { + internal *protocol.Error + actual error +} + +// Err return the protocol.Error that can be sent over the wire. +func (ie InternalError) Err() *protocol.Error { + return ie.internal +} + +// String return the actual underlying error. +func (ie InternalError) String() string { + i := ie.internal + return fmt.Sprintf("%v [%v:%v]", ie.actual.Error(), i.Trace, i.Timestamp) +} + +// Error satifies the error interface. +func (ie InternalError) Error() string { + if ie.internal == nil { + return "internal error" + } + return ie.internal.String() +} + +func NewInternalErrorf(msg string, args ...interface{}) *InternalError { + return &InternalError{ + internal: protocol.Errorf("internal error"), + actual: fmt.Errorf(msg, args...), + } +} + +// Wrap for calling bfg commands +type bfgCmd struct { + msg any + ch chan any +} + +func NewDefaultConfig() *Config { + return &Config{ + BFGURL: bfgapi.DefaultPrivateURL, + ListenAddress: bssapi.DefaultListen, + } +} + +type Config struct { + BFGURL string + ListenAddress string + LogLevel string + PrometheusListenAddress string +} + +type Server struct { + mtx sync.RWMutex + wg sync.WaitGroup + + cfg *Config + + currentKeystone *hemi.Header + previousKeystone *hemi.Header + + // requests + requestLimit int // Request limiter queue depth + requestLimiter chan bool // Maximum in progress websocket commands + + // BFG + bfgWG sync.WaitGroup // websocket read exit + bfgCmdCh chan bfgCmd // commands to send to bfg + bfgCallTimeout time.Duration // BFG call timeout + holdoffTimeout time.Duration // Time in between connections attempt to BFG + + // Prometheus + cmdsProcessed prometheus.Counter + isRunning bool + bfgConnected bool + + // sessions is a record of websocket connections and their respective + // request contexts + requestTimeout time.Duration // Request timeout, must be 2X BFG call timeout + sessions map[string]*bssWs // Session id to connections map +} + +func DerivePopPayoutFromPopTx(popTx bfgapi.PopTx) bssapi.PopPayout { + amount := big.NewInt(hemi.HEMIBase) + + return bssapi.PopPayout{ + // as of now, this is static at 10^18 atomic units == 1 HEMI + Amount: amount, + MinerAddress: ethereum.PublicKeyToAddress(popTx.PopMinerPublicKey), + } +} + +// XXX this function needs documentation. It is not obvious what it does. +func ConvertPopTxsToPopPayouts(popTxs []bfgapi.PopTx) []bssapi.PopPayout { + popPayoutsMapping := make(map[string]bssapi.PopPayout) + + for _, v := range popTxs { + popPayout := DerivePopPayoutFromPopTx(v) + key := popPayout.MinerAddress.String() + existingPopPayout, ok := popPayoutsMapping[key] + if !ok { + popPayoutsMapping[key] = popPayout + continue + } + + popPayoutsMapping[key] = bssapi.PopPayout{ + MinerAddress: existingPopPayout.MinerAddress, + Amount: existingPopPayout.Amount.Add(existingPopPayout.Amount, popPayout.Amount), + } + } + + popPayouts := []bssapi.PopPayout{} + + for k := range popPayoutsMapping { + popPayouts = append(popPayouts, popPayoutsMapping[k]) + } + + return popPayouts +} + +func NewServer(cfg *Config) (*Server, error) { + if cfg == nil { + cfg = NewDefaultConfig() + } + defaultRequestTimeout := 11 * time.Second // XXX + requestLimit := 1000 // XXX + s := &Server{ + cfg: cfg, + requestLimiter: make(chan bool, requestLimit), + bfgCmdCh: make(chan bfgCmd, 10), + cmdsProcessed: prometheus.NewCounter(prometheus.CounterOpts{ + Subsystem: promSubsystem, + Name: "rpc_calls_total", + Help: "The total number of succesful RPC commands", + }), + requestTimeout: defaultRequestTimeout, + bfgCallTimeout: defaultRequestTimeout / 2, + holdoffTimeout: 6 * time.Second, + requestLimit: requestLimit, + sessions: make(map[string]*bssWs), + } + for i := 0; i < requestLimit; i++ { + s.requestLimiter <- true + } + + return s, nil +} + +// handleRequest is called as a go routine to handle a long lived command. +func (s *Server) handleRequest(parrentCtx context.Context, bws *bssWs, wsid string, requestType string, handler func(ctx context.Context) (any, error)) { + log.Tracef("handleRequest: %v", bws.addr) + defer log.Tracef("handleRequest exit: %v", bws.addr) + + ctx, cancel := context.WithTimeout(parrentCtx, s.requestTimeout) + defer cancel() + + select { + case <-s.requestLimiter: + default: + log.Infof("Request limiter hit %v: %v", bws.addr, requestType) + <-s.requestLimiter + } + defer func() { s.requestLimiter <- true }() + + log.Tracef("Handling request %v: %v", bws.addr, requestType) + + response, err := handler(ctx) + if err != nil { + // XXX these errors print an invalid trace for some reason. It + // mostly works but have a look at it and compare with client + // output and fix. + var ie *InternalError + if errors.As(err, &ie) { + log.Errorf("[INTERNAL ERROR] Failed to handle %v request %v: %v", + requestType, bws.addr, ie.String()) + } else { + // This may be too loud and can be silenced once in production. + log.Errorf("Failed to handle %v request %v: %v", + requestType, bws.addr, err) + } + } + if response == nil { + return + } + + log.Debugf("Responding to %v request with %v", requestType, spew.Sdump(response)) + + if err := bssapi.Write(ctx, bws.conn, wsid, response); err != nil { + log.Errorf("Failed to handle %v request: protocol write failed: %v", requestType, err) + } +} + +type bssWs struct { + wg sync.WaitGroup + addr string + conn *protocol.WSConn + sessionId string + requestContext context.Context +} + +func (s *Server) handlePingRequest(ctx context.Context, bws *bssWs, payload any, id string) error { + log.Tracef("handlePingRequest: %v", bws.addr) + defer log.Tracef("handlePingRequest exit: %v", bws.addr) + + p, ok := payload.(*bssapi.PingRequest) + if !ok { + return fmt.Errorf("handlePingRequest invalid payload type: %T", payload) + } + response := &bssapi.PingResponse{ + OriginTimestamp: p.Timestamp, + Timestamp: time.Now().Unix(), + } + + log.Tracef("responding with %v", spew.Sdump(response)) + + if err := bssapi.Write(ctx, bws.conn, id, response); err != nil { + return fmt.Errorf("handlePingRequest write: %v %v", + bws.addr, err) + } + return nil +} + +func (s *Server) handlePopPayoutsRequest(ctx context.Context, msg *bssapi.PopPayoutsRequest) (*bssapi.PopPayoutsResponse, error) { + log.Tracef("handlePopPayoutsRequest") + defer log.Tracef("handlePopPayoutsRequest exit") + + popTxsForL2BlockRequest := bfgapi.PopTxsForL2BlockRequest{ + L2Block: msg.L2BlockForPayout, + } + + popTxsForL2BlockRes, err := s.callBFG(ctx, &popTxsForL2BlockRequest) + if err != nil { + return &bssapi.PopPayoutsResponse{ + Error: protocol.Errorf("%v", err), + }, err + } + + popPayouts := ConvertPopTxsToPopPayouts( + (popTxsForL2BlockRes.(*bfgapi.PopTxsForL2BlockResponse)).PopTxs, + ) + + popPayoutsResponse := bssapi.PopPayoutsResponse{ + PopPayouts: popPayouts, + } + + return &popPayoutsResponse, nil +} + +func (s *Server) handleL2KeytoneRequest(ctx context.Context, msg *bssapi.L2KeystoneRequest) (*bssapi.L2KeystoneResponse, error) { + log.Tracef("handleL2KeytoneRequest") + defer log.Tracef("handleL2KeytoneRequest exit") + + newKeystoneHeadersRequest := bfgapi.NewL2KeystonesRequest{ + L2Keystones: []hemi.L2Keystone{ + msg.L2Keystone, + }, + } + + resp := &bssapi.L2KeystoneResponse{} + _, err := s.callBFG(ctx, &newKeystoneHeadersRequest) + if err != nil { + resp.Error = protocol.Errorf("%v", err) + } + + return resp, err +} + +func (s *Server) handleBtcFinalityByRecentKeystonesRequest(ctx context.Context, msg *bssapi.BTCFinalityByRecentKeystonesRequest) (*bssapi.BTCFinalityByRecentKeystonesResponse, error) { + request := bfgapi.BTCFinalityByRecentKeystonesRequest{ + NumRecentKeystones: msg.NumRecentKeystones, + } + + response, err := s.callBFG(ctx, &request) + if err != nil { + return &bssapi.BTCFinalityByRecentKeystonesResponse{ + Error: protocol.Errorf("%v", err), + }, err + } + + return &bssapi.BTCFinalityByRecentKeystonesResponse{ + L2BTCFinalities: response.(*bfgapi.BTCFinalityByRecentKeystonesResponse).L2BTCFinalities, + }, nil +} + +func (s *Server) handleBtcFinalityByKeystonesRequest(ctx context.Context, msg *bssapi.BTCFinalityByKeystonesRequest) (*bssapi.BTCFinalityByKeystonesResponse, error) { + request := bfgapi.BTCFinalityByKeystonesRequest{ + L2Keystones: msg.L2Keystones, + } + + response, err := s.callBFG(ctx, &request) + if err != nil { + return &bssapi.BTCFinalityByKeystonesResponse{ + Error: protocol.Errorf("%v", err), + }, err + } + + return &bssapi.BTCFinalityByKeystonesResponse{ + L2BTCFinalities: response.(*bfgapi.BTCFinalityByKeystonesResponse).L2BTCFinalities, + }, nil +} + +func (s *Server) handleWebsocketRead(ctx context.Context, bws *bssWs) { + defer bws.wg.Done() + + log.Tracef("handleWebsocketRead: %v", bws.addr) + defer log.Tracef("handleWebsocketRead exit: %v", bws.addr) + + for { + cmd, id, payload, err := bssapi.Read(ctx, bws.conn) + if err != nil { + // Don't log normal close errors. + var ce websocket.CloseError + if !errors.As(err, &ce) { + log.Errorf("handleWebsocketRead: %v", err) + } else { + log.Tracef("handleWebsocketRead: %v", err) + } + return + } + + // May be too loud. + log.Tracef("handleWebsocketRead read %v: %v %v %v", + bws.addr, cmd, id, spew.Sdump(payload)) + + // Note, we MUST NOT shadow ctx in the callbacks. ctx *is* the + // base context in the callback thus if we shadow it we + // overwrite the correct context that has a reasonable timeout. + // + // Make dead sure all contexts folloing in this code are not + // shadowed. + switch cmd { + case bssapi.CmdPingRequest: + // quick call + err = s.handlePingRequest(ctx, bws, payload, id) + case bssapi.CmdPopPayoutRequest: + handler := func(c context.Context) (any, error) { + msg := payload.(*bssapi.PopPayoutsRequest) + return s.handlePopPayoutsRequest(c, msg) + } + + go s.handleRequest(ctx, bws, id, "handle pop payouts request", handler) + case bssapi.CmdL2KeystoneRequest: + handler := func(c context.Context) (any, error) { + msg := payload.(*bssapi.L2KeystoneRequest) + return s.handleL2KeytoneRequest(c, msg) + } + + go s.handleRequest(ctx, bws, id, "handle l2 keystone request", handler) + case bssapi.CmdBTCFinalityByRecentKeystonesRequest: + handler := func(c context.Context) (any, error) { + msg := payload.(*bssapi.BTCFinalityByRecentKeystonesRequest) + return s.handleBtcFinalityByRecentKeystonesRequest(c, msg) + } + + go s.handleRequest(ctx, bws, id, "handle handleBtcFinalityByRecentKeystonesRequest", handler) + case bssapi.CmdBTCFinalityByKeystonesRequest: + handler := func(c context.Context) (any, error) { + msg := payload.(*bssapi.BTCFinalityByKeystonesRequest) + return s.handleBtcFinalityByKeystonesRequest(c, msg) + } + + go s.handleRequest(ctx, bws, id, "handle handleBtcFinalityByKeystonesRequest", handler) + default: + err = fmt.Errorf("unknown command: %v", cmd) + } + + // If set, it is a terminal error. + if err != nil { + log.Errorf("handleWebsocketRead %v %v %v: %v", + bws.addr, cmd, id, err) + bws.conn.CloseStatus(websocket.StatusProtocolError, + err.Error()) + return + } + + // Command completed + s.cmdsProcessed.Inc() + } +} + +func (s *Server) handleWebsocket(w http.ResponseWriter, r *http.Request) { + log.Tracef("handleWebsocket: %v", r.RemoteAddr) + defer log.Tracef("handleWebsocket exit: %v", r.RemoteAddr) + + wao := &websocket.AcceptOptions{ + CompressionMode: websocket.CompressionContextTakeover, + OriginPatterns: []string{"localhost"}, + // InsecureSkipVerify: true, // XXX - configure OriginPatterns instead + } + conn, err := websocket.Accept(w, r, wao) + if err != nil { + log.Errorf("Failed to accept websocket connection for %v: %v", + r.RemoteAddr, err) + return + } + defer conn.Close(websocket.StatusNormalClosure, "") // Force shutdown connection + + // Increase read limit to 128KB + conn.SetReadLimit(128 * 1024) // XXX push this into protocol + + bws := &bssWs{ + addr: r.RemoteAddr, + conn: protocol.NewWSConn(conn), + requestContext: r.Context(), + } + + if bws.sessionId, err = s.newSession(bws); err != nil { + log.Errorf("error occurred creating key: %s", err) + return + } + + defer func() { + s.deleteSession(bws.sessionId) + conn.Close(websocket.StatusNormalClosure, "") // Force shutdown connection + }() + + bws.wg.Add(1) + go s.handleWebsocketRead(r.Context(), bws) + + // Always ping, required by protocol. + ping := &bssapi.PingRequest{ + Timestamp: time.Now().Unix(), + } + + log.Tracef("responding with %v", spew.Sdump(ping)) + if err := bssapi.Write(r.Context(), bws.conn, "0", ping); err != nil { + log.Errorf("Write: %v", err) + } + + log.Infof("Unauthenticated connection from %v", r.RemoteAddr) + + // Wait for termination + bws.wg.Wait() + + log.Infof("Unauthenticated connection terminated from %v", r.RemoteAddr) +} + +func (s *Server) newSession(bws *bssWs) (string, error) { + b := make([]byte, 16) + + for { + // create random value and encode to string + _, err := rand.Read(b) + if err != nil { + return "", err + } + id := hex.EncodeToString(b) + + // does this random value exist? if so try again + s.mtx.Lock() + if _, ok := s.sessions[id]; ok { + s.mtx.Unlock() + continue + } + s.sessions[id] = bws + s.mtx.Unlock() + + return id, nil + } +} + +func (s *Server) deleteSession(id string) { + s.mtx.Lock() + if _, ok := s.sessions[id]; !ok { + log.Errorf("id not found in sessions %s", id) + } + delete(s.sessions, id) + s.mtx.Unlock() +} + +func writeNotificationResponse(bws *bssWs, response any) { + if err := bssapi.Write(bws.requestContext, bws.conn, "", response); err != nil { + log.Errorf( + "handleBtcFinalityNotification write: %v %v", + bws.addr, + err, + ) + } +} + +func (s *Server) handleBtcFinalityNotification() error { + response := bssapi.BTCFinalityNotification{} + + s.mtx.Lock() + for _, bws := range s.sessions { + go writeNotificationResponse(bws, response) + } + s.mtx.Unlock() + + return nil +} + +func (s *Server) handleBtcBlockNotification() error { + response := bssapi.BTCNewBlockNotification{} + + s.mtx.Lock() + for _, bws := range s.sessions { + go writeNotificationResponse(bws, response) + } + s.mtx.Unlock() + + return nil +} + +func handle(service string, mux *http.ServeMux, pattern string, handler func(http.ResponseWriter, *http.Request)) { + mux.HandleFunc(pattern, handler) + log.Infof("handle (%v): %v", service, pattern) +} + +func (s *Server) handleBFGWebsocketReadUnauth(ctx context.Context, conn *protocol.Conn) { + defer s.bfgWG.Done() + + log.Tracef("handleBFGWebsocketReadUnauth") + defer log.Tracef("handleBFGWebsocketReadUnauth exit") + s.setBFGConnected(conn.IsOnline()) // this is a bit inaccurate because on reconeect the code does not get past the ReadConn call. Moving the call into the for would be bouncing so let's assume bfg chatters soon so that the connection is marked online. + for { + log.Infof("handleBFGWebsocketReadUnauth %v", "ReadConn") + cmd, rid, payload, err := bfgapi.ReadConn(ctx, conn) + if err != nil { + s.setBFGConnected(conn.IsOnline()) + // See if we were terminated + select { + case <-ctx.Done(): + return + case <-time.After(s.holdoffTimeout): + } + continue + } + s.setBFGConnected(conn.IsOnline()) + log.Infof("handleBFGWebsocketReadUnauth %v", cmd) + + switch cmd { + case bfgapi.CmdPingRequest: + p := payload.(*bfgapi.PingRequest) + response := &bfgapi.PingResponse{ + OriginTimestamp: p.Timestamp, + Timestamp: time.Now().Unix(), + } + if err := bfgapi.Write(ctx, conn, rid, response); err != nil { + log.Errorf("handleBFGWebsocketReadUnauth write: %v", + err) + } + case bfgapi.CmdBTCFinalityNotification: + go s.handleBtcFinalityNotification() + case bfgapi.CmdBTCNewBlockNotification: + go s.handleBtcBlockNotification() + default: + log.Errorf("unknown command: %v", cmd) + return // XXX exit for now to cause a ruckus in the logs + } + } +} + +func (s *Server) handleBFGCallCompletion(parrentCtx context.Context, conn *protocol.Conn, bc bfgCmd) { + log.Tracef("handleBFGCallCompletion") + defer log.Tracef("handleBFGCallCompletion exit") + + ctx, cancel := context.WithTimeout(parrentCtx, s.bfgCallTimeout) + defer cancel() + + log.Tracef("handleBFGCallCompletion: %v", spew.Sdump(bc.msg)) + + _, _, payload, err := bfgapi.Call(ctx, conn, bc.msg) + if err != nil { + log.Errorf("handleBFGCallCompletion %T: %v", bc.msg, err) + select { + case bc.ch <- err: + default: + } + } + select { + case bc.ch <- payload: + log.Tracef("handleBFGCallCompletion returned: %v", spew.Sdump(payload)) + default: + } +} + +func (s *Server) handleBFGWebsocketCallUnauth(ctx context.Context, conn *protocol.Conn) { + defer s.bfgWG.Done() + + log.Tracef("handleBFGWebsocketCallUnauth") + defer log.Tracef("handleBFGWebsocketCallUnauth exit") + for { + select { + case <-ctx.Done(): + return + case bc := <-s.bfgCmdCh: + go s.handleBFGCallCompletion(ctx, conn, bc) + } + } +} + +func (s *Server) callBFG(parrentCtx context.Context, msg any) (any, error) { + log.Tracef("callBFG %T", msg) + defer log.Tracef("callBFG exit %T", msg) + + bc := bfgCmd{ + msg: msg, + ch: make(chan any), + } + + ctx, cancel := context.WithTimeout(parrentCtx, s.bfgCallTimeout) + defer cancel() + + // attempt to send + select { + case <-ctx.Done(): + return nil, NewInternalErrorf("callBFG send context error: %v", + ctx.Err()) + case s.bfgCmdCh <- bc: + default: + return nil, NewInternalErrorf("bfg command queue full") + } + + // Wait for response + select { + case <-ctx.Done(): + return nil, NewInternalErrorf("callBFG received context error: %v", + ctx.Err()) + case payload := <-bc.ch: + if err, ok := payload.(error); ok { + return nil, err // XXX is this an error or internal error + } + return payload, nil + } + + // Won't get here +} + +func (s *Server) connectBFG(ctx context.Context) error { + log.Tracef("connectBFG") + defer log.Tracef("connectBFG exit") + + conn, err := protocol.NewConn(s.cfg.BFGURL, nil) + if err != nil { + return err + } + err = conn.Connect(ctx) + if err != nil { + return err + } + + s.bfgWG.Add(1) + go s.handleBFGWebsocketCallUnauth(ctx, conn) + + s.bfgWG.Add(1) + go s.handleBFGWebsocketReadUnauth(ctx, conn) + + // Wait for exit + s.bfgWG.Wait() + + return nil +} + +func (s *Server) bfg(ctx context.Context) { + defer s.wg.Done() + + log.Tracef("bfg") + defer log.Tracef("bfg exit") + + for { + if err := s.connectBFG(ctx); err != nil { + // Do nothing + log.Tracef("connectBFG: %v", err) + } else { + log.Infof("Connected to BFG: %s", s.cfg.BFGURL) + } + // See if we were terminated + select { + case <-ctx.Done(): + return + case <-time.After(s.holdoffTimeout): + } + + log.Debugf("Reconnecting to: %v", s.cfg.BFGURL) + } +} + +func (s *Server) running() bool { + s.mtx.Lock() + defer s.mtx.Unlock() + return s.isRunning +} + +func (s *Server) testAndSetRunning(b bool) bool { + s.mtx.Lock() + defer s.mtx.Unlock() + old := s.isRunning + s.isRunning = b + return old != s.isRunning +} + +func (s *Server) promRunning() float64 { + r := s.running() + if r { + return 1 + } + return 0 +} + +func (s *Server) setBFGConnected(x bool) { + s.mtx.Lock() + s.bfgConnected = x + s.mtx.Unlock() +} + +func (s *Server) isBFGConnected() float64 { + s.mtx.Lock() + c := s.bfgConnected + s.mtx.Unlock() + if c { + return 1 + } + return 0 +} + +func (s *Server) Run(parrentCtx context.Context) error { + log.Tracef("Run") + defer log.Tracef("Run exit") + + if !s.testAndSetRunning(true) { + return fmt.Errorf("bss already running") + } + defer s.testAndSetRunning(false) + + ctx, cancel := context.WithCancel(parrentCtx) + defer cancel() // just in case + + mux := http.NewServeMux() + handle("bss", mux, bssapi.RouteWebsocket, s.handleWebsocket) + + httpServer := &http.Server{ + Addr: s.cfg.ListenAddress, + Handler: mux, + BaseContext: func(net.Listener) context.Context { return ctx }, + } + httpErrCh := make(chan error) + go func() { + log.Infof("Listening: %v", s.cfg.ListenAddress) + httpErrCh <- httpServer.ListenAndServe() + }() + defer func() { + if err := httpServer.Shutdown(ctx); err != nil { + log.Errorf("http server exit: %v", err) + return + } + log.Infof("RPC server shutdown cleanly") + }() + + s.wg.Add(1) + go s.bfg(ctx) // Attempt to talk to bfg + + // Prometheus + if s.cfg.PrometheusListenAddress != "" { + d, err := deucalion.New(&deucalion.Config{ + ListenAddress: s.cfg.PrometheusListenAddress, + }) + if err != nil { + return fmt.Errorf("failed to create server: %w", err) + } + cs := []prometheus.Collector{ + s.cmdsProcessed, + prometheus.NewGaugeFunc(prometheus.GaugeOpts{ + Subsystem: promSubsystem, + Name: "running", + Help: "Is bss service running.", + }, s.promRunning), + prometheus.NewGaugeFunc(prometheus.GaugeOpts{ + Subsystem: promSubsystem, + Name: "bfg_connected", + Help: "Is bss connected to bfg.", + }, s.isBFGConnected), + } + s.wg.Add(1) + go func() { + defer s.wg.Done() + if err := d.Run(ctx, cs); err != context.Canceled { + log.Errorf("prometheus terminated with error: %v", err) + return + } + log.Infof("prometheus clean shutdown") + }() + } + + var err error + select { + case <-ctx.Done(): + err = ctx.Err() + case err = <-httpErrCh: + } + cancel() + + log.Infof("bss service shutting down") + + s.wg.Wait() + log.Infof("bss service clean shutdown") + + return err +} diff --git a/service/bss/bss_test.go b/service/bss/bss_test.go new file mode 100644 index 00000000..29d2f70b --- /dev/null +++ b/service/bss/bss_test.go @@ -0,0 +1,150 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package bss + +import ( + "math/big" + "slices" + "testing" + + "github.com/go-test/deep" + + "github.com/hemilabs/heminetwork/api/bfgapi" + "github.com/hemilabs/heminetwork/api/bssapi" + "github.com/hemilabs/heminetwork/ethereum" + "github.com/hemilabs/heminetwork/hemi" +) + +func TestConvertPopTxsToPopPayouts(t *testing.T) { + type testCaseDef struct { + name string + popTxs []bfgapi.PopTx + expectedPopPayouts []bssapi.PopPayout + extraSetup func(*testCaseDef) + } + + popMinerPublicKeyOne := []byte("popMinerPublicKeyOne") + popMinerPublicKeyTwo := []byte("popMinerPublicKeyTwo") + popMinerAddressOne := ethereum.PublicKeyToAddress(popMinerPublicKeyOne) + popMinerAddressTwo := ethereum.PublicKeyToAddress(popMinerPublicKeyTwo) + + testTable := []testCaseDef{ + { + name: "convert empty poptxs", + popTxs: []bfgapi.PopTx{}, + expectedPopPayouts: []bssapi.PopPayout{}, + }, + { + name: "convert no duplicate miner addresses", + popTxs: []bfgapi.PopTx{ + { + PopMinerPublicKey: popMinerPublicKeyOne, + }, + { + PopMinerPublicKey: popMinerPublicKeyTwo, + }, + }, + expectedPopPayouts: []bssapi.PopPayout{ + { + MinerAddress: popMinerAddressOne, + Amount: big.NewInt(hemi.HEMIBase), + }, + { + MinerAddress: popMinerAddressTwo, + Amount: big.NewInt(hemi.HEMIBase), + }, + }, + }, + { + name: "convert reduce duplicate miner addresses", + popTxs: []bfgapi.PopTx{ + { + PopMinerPublicKey: popMinerPublicKeyOne, + }, + { + PopMinerPublicKey: popMinerPublicKeyTwo, + }, + { + PopMinerPublicKey: popMinerPublicKeyOne, + }, + }, + expectedPopPayouts: []bssapi.PopPayout{ + { + MinerAddress: popMinerAddressOne, + Amount: big.NewInt(2 * hemi.HEMIBase), + }, + { + MinerAddress: popMinerAddressTwo, + Amount: big.NewInt(hemi.HEMIBase), + }, + }, + }, + { + name: "convert reduce duplicate miner addresses large number", + popTxs: []bfgapi.PopTx{}, + expectedPopPayouts: []bssapi.PopPayout{ + { + MinerAddress: popMinerAddressOne, + Amount: big.NewInt(0).Mul(big.NewInt(1*hemi.HEMIBase), big.NewInt(360)), + }, + }, + extraSetup: func(tcd *testCaseDef) { + for i := 0; i < 360; i++ { + tcd.popTxs = append(tcd.popTxs, bfgapi.PopTx{ + PopMinerPublicKey: popMinerPublicKeyOne, + }) + } + }, + }, + } + + for _, testCase := range testTable { + t.Run(testCase.name, func(t *testing.T) { + if testCase.extraSetup != nil { + testCase.extraSetup(&testCase) + } + + popPayouts := ConvertPopTxsToPopPayouts(testCase.popTxs) + + sortFn := func(a, b bssapi.PopPayout) int { + // find first differing byte in miner addresses and sort by that, + // this should lead to predictable ordering as + // miner addresses are unique here + + var ab byte = 0 + var bb byte = 0 + + for i := 0; i < len(a.MinerAddress); i++ { + ab = a.MinerAddress[i] + bb = b.MinerAddress[i] + if ab != bb { + break + } + } + + if ab > bb { + return -1 + } + + return 1 + } + + // sort to ensure expected order + slices.SortFunc(popPayouts, sortFn) + slices.SortFunc(testCase.expectedPopPayouts, sortFn) + + diff := deep.Equal(popPayouts, testCase.expectedPopPayouts) + if len(diff) != 0 { + t.Fatalf("unexpected diff %s", diff) + } + + for i := range popPayouts { + if popPayouts[i].Amount.Cmp(testCase.expectedPopPayouts[i].Amount) != 0 { + t.Fatalf("amounts not equal: %v != %v", popPayouts[i].Amount, testCase.expectedPopPayouts[i].Amount) + } + } + }) + } +} diff --git a/service/deucalion/deucalion.go b/service/deucalion/deucalion.go new file mode 100644 index 00000000..ed3ac2d9 --- /dev/null +++ b/service/deucalion/deucalion.go @@ -0,0 +1,199 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +// Package deucalion provides an easy-to-use Prometheus metrics server. +// +// The deucalion package provides an automatic Prometheus server that will +// start automatically when the PROMETHEUS_ADDRESS environment variable is set. +// To use the deucalion package only for the automatic metrics server, you may +// import it as: +// +// import _ "github.com/hemilabs/heminetwork/service/deucalion" +// +// After being imported, the application may be run with the PROMETHEUS_ADDRESS +// environment variable set to an address to start the Prometheus metrics server: +// +// PROMETHEUS_ADDRESS=localhost:2112 myapp +// +// The deucalion package may also be used to create a Prometheus metrics +// server with custom collectors: +// +// d, _ := deucalion.New(&deucalion.Config{ +// ListenAddress: PrometheusListenAddress, +// }) +// +// _ = d.Run(ctx, []prometheus.Collector{}) +package deucalion + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "sync" + + "github.com/juju/loggo" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" + "github.com/prometheus/client_golang/prometheus/promhttp" + + "github.com/hemilabs/heminetwork/config" +) + +const ( + daemonName = "deucalion" + defaultLogLevel = daemonName + "=INFO" +) + +var ( + log = loggo.GetLogger(daemonName) + + cfg = NewDefaultConfig() + cm = config.CfgMap{ + "DEUCALION_LOG_LEVEL": config.Config{ + Value: &cfg.logLevel, + DefaultValue: defaultLogLevel, + Help: "loglevel for this package; INFO, DEBUG and TRACE", + Print: config.PrintAll, + }, + "PROMETHEUS_ADDRESS": config.Config{ + Value: &cfg.ListenAddress, + DefaultValue: "", // bssapi.DefaultPrometheusListen, + Help: "address and port automatic prometheus listens on", + Print: config.PrintAll, + }, + } +) + +func init() { + if err := config.Parse(cm); err != nil { + panic(fmt.Errorf("could not parse config during init: %v", err)) + } + if cfg.ListenAddress == "" { + return + } + + loggo.ConfigureLoggers(cfg.logLevel) + + // launch prometheus automatically + ctx := context.Background() + d, err := New(cfg) + if err != nil { + panic(fmt.Errorf("failed to create server: %v", err)) + } + go func() { + if err = d.Run(ctx, nil); !errors.Is(err, context.Canceled) { + log.Errorf("Deucalion server terminated with error: %v", err) + } + }() +} + +type Config struct { + logLevel string + ListenAddress string +} + +func NewDefaultConfig() *Config { + return &Config{ + logLevel: defaultLogLevel, + ListenAddress: "", // localhost:2112 + } +} + +type Deucalion struct { + mtx sync.RWMutex + wg sync.WaitGroup + isRunning bool + cfg *Config +} + +func New(cfg *Config) (*Deucalion, error) { + return &Deucalion{cfg: cfg}, nil +} + +func handle(service string, mux *http.ServeMux, pattern string, handler func(http.ResponseWriter, *http.Request)) { + mux.HandleFunc(pattern, handler) + log.Infof("handle (%v): %v", service, pattern) +} + +func (d *Deucalion) running() bool { + d.mtx.RLock() + defer d.mtx.RUnlock() + return d.isRunning +} + +func (d *Deucalion) testAndSetRunning(b bool) bool { + d.mtx.Lock() + defer d.mtx.Unlock() + old := d.isRunning + d.isRunning = b + return old != d.isRunning +} + +func (d *Deucalion) Run(ctx context.Context, cs []prometheus.Collector) error { + if !d.testAndSetRunning(true) { + return fmt.Errorf("already running") + } + defer d.testAndSetRunning(false) + + if d.cfg.ListenAddress == "" { + return fmt.Errorf("listen address is required") + } + + reg := prometheus.NewRegistry() + allCollectors := []prometheus.Collector{ + collectors.NewBuildInfoCollector(), + collectors.NewGoCollector(), + collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}), + } + if cs != nil { + allCollectors = append(allCollectors, cs...) + } + for _, c := range allCollectors { + if err := reg.Register(c); err != nil { + return fmt.Errorf("register collector: %w", err) + } + } + + prometheusMux := http.NewServeMux() + handle("prometheus", prometheusMux, "/metrics", promhttp.HandlerFor(reg, + promhttp.HandlerOpts{Registry: reg}).ServeHTTP) + httpPrometheusServer := &http.Server{ + Addr: d.cfg.ListenAddress, + Handler: prometheusMux, + BaseContext: func(net.Listener) context.Context { return ctx }, + } + httpPrometheusErrCh := make(chan error) + go func() { + log.Infof("Prometheus listening: %v", d.cfg.ListenAddress) + httpPrometheusErrCh <- httpPrometheusServer.ListenAndServe() + }() + defer func() { + if err := httpPrometheusServer.Shutdown(ctx); err != nil { + log.Errorf("http prometheus server exit: %v", err) + } + }() + + var ( + done bool + err error + ) + for !done { + select { + case <-ctx.Done(): + err = ctx.Err() + done = true + case err = <-httpPrometheusErrCh: + return err + } + } + + log.Infof("deucalion service shutting down") + + d.wg.Wait() + log.Infof("deucalion service clean shutdown") + + return err +} diff --git a/service/popm/popm.go b/service/popm/popm.go new file mode 100644 index 00000000..3b2e37b8 --- /dev/null +++ b/service/popm/popm.go @@ -0,0 +1,787 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package popm + +import ( + "bytes" + "cmp" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/http" + "slices" + "strings" + "sync" + "time" + + "github.com/btcsuite/btcd/btcutil" + btcchaincfg "github.com/btcsuite/btcd/chaincfg" + btcchainhash "github.com/btcsuite/btcd/chaincfg/chainhash" + btctxscript "github.com/btcsuite/btcd/txscript" + btcwire "github.com/btcsuite/btcd/wire" + "github.com/davecgh/go-spew/spew" + dcrsecp256k1 "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/juju/loggo" + + "github.com/hemilabs/heminetwork/api/auth" + "github.com/hemilabs/heminetwork/api/bfgapi" + "github.com/hemilabs/heminetwork/api/protocol" + "github.com/hemilabs/heminetwork/bitcoin" + "github.com/hemilabs/heminetwork/hemi" + "github.com/hemilabs/heminetwork/hemi/pop" +) + +// XXX we should debate if we can make pop miner fully transient. It feels like +// it should be. + +const ( + logLevel = "INFO" + + promSubsystem = "popm_service" // Prometheus +) + +var log = loggo.GetLogger("popm") + +func init() { + loggo.ConfigureLoggers(logLevel) +} + +type Config struct { + // BFGWSURL specifies the URL of the BFG private websocket endpoint + BFGWSURL string + + // BTCChainName specifies the name of the Bitcoin chain that + // this PoP miner is operating on. + BTCChainName string // XXX are we brave enough to rename this BTCNetwork? + + // BTCPrivateKey provides a BTC private key as a string of + // hexadecimal digits. + BTCPrivateKey string + + LogLevel string + + PrometheusListenAddress string +} + +func NewDefaultConfig() *Config { + return &Config{ + BFGWSURL: "http://localhost:8383/v1/ws/public", + BTCChainName: "testnet3", + } +} + +type bfgCmd struct { + msg any + ch chan any +} + +type Miner struct { + mtx sync.RWMutex + wg sync.WaitGroup + + holdoffTimeout time.Duration + requestTimeout time.Duration + + cfg *Config + + btcChainParams *btcchaincfg.Params + btcPrivateKey *dcrsecp256k1.PrivateKey + btcPublicKey *dcrsecp256k1.PublicKey + btcAddress *btcutil.AddressPubKeyHash + + lastKeystone *hemi.L2Keystone + keystoneCh chan *hemi.L2Keystone + + // Prometheus + isRunning bool + + bfgWg sync.WaitGroup + bfgCmdCh chan bfgCmd // commands to send to bfg +} + +func NewMiner(cfg *Config) (*Miner, error) { + if cfg == nil { + cfg = NewDefaultConfig() + } + + m := &Miner{ + cfg: cfg, + keystoneCh: make(chan *hemi.L2Keystone, 3), + bfgCmdCh: make(chan bfgCmd, 10), + holdoffTimeout: 5 * time.Second, + requestTimeout: 5 * time.Second, + } + + switch strings.ToLower(cfg.BTCChainName) { + case "mainnet": + m.btcChainParams = &btcchaincfg.MainNetParams + case "testnet", "testnet3": + m.btcChainParams = &btcchaincfg.TestNet3Params + default: + return nil, fmt.Errorf("unknown BTC chain name %q", cfg.BTCChainName) + } + + if cfg.BTCPrivateKey == "" { + return nil, errors.New("no BTC private key provided") + } + var err error + m.btcPrivateKey, m.btcPublicKey, m.btcAddress, err = bitcoin.KeysAndAddressFromHexString(cfg.BTCPrivateKey, m.btcChainParams) + if err != nil { + return nil, err + } + return m, nil +} + +func (m *Miner) bitcoinBalance(ctx context.Context, scriptHash []byte) (uint64, int64, error) { + br := &bfgapi.BitcoinBalanceRequest{ + ScriptHash: scriptHash, + } + + res, err := m.callBFG(ctx, m.requestTimeout, br) + if err != nil { + return 0, 0, err + } + + bResp, ok := res.(*bfgapi.BitcoinBalanceResponse) + if !ok { + return 0, 0, fmt.Errorf("not a BitcoinBalanceResponse %T", res) + } + + return bResp.Confirmed, bResp.Unconfirmed, nil +} + +func (m *Miner) bitcoinBroadcast(ctx context.Context, tx []byte) ([]byte, error) { + bbr := &bfgapi.BitcoinBroadcastRequest{ + Transaction: tx, + } + res, err := m.callBFG(ctx, m.requestTimeout, bbr) + if err != nil { + return nil, err + } + + bbResp, ok := res.(*bfgapi.BitcoinBroadcastResponse) + if !ok { + return nil, fmt.Errorf("not a bitcoin broadcast response %T", res) + } + + return bbResp.TXID, nil +} + +func (m *Miner) bitcoinHeight(ctx context.Context) (uint64, error) { + bir := &bfgapi.BitcoinInfoRequest{} + + res, err := m.callBFG(ctx, m.requestTimeout, bir) + if err != nil { + return 0, err + } + + biResp, ok := res.(*bfgapi.BitcoinInfoResponse) + if !ok { + return 0, fmt.Errorf("not a BitcoinIfnoResponse") + } + + return biResp.Height, nil +} + +func (m *Miner) bitcoinUTXOs(ctx context.Context, scriptHash []byte) ([]*bfgapi.BitcoinUTXO, error) { + bur := &bfgapi.BitcoinUTXOsRequest{ + ScriptHash: scriptHash, + } + + res, err := m.callBFG(ctx, m.requestTimeout, bur) + if err != nil { + return nil, err + } + + buResp, ok := res.(*bfgapi.BitcoinUTXOsResponse) + if !ok { + return nil, fmt.Errorf("not a buResp %T", res) + } + + return buResp.UTXOs, nil +} + +func pickUTXOs(utxos []*bfgapi.BitcoinUTXO, amount int64) ([]*bfgapi.BitcoinUTXO, error) { + um := make(map[int]*bfgapi.BitcoinUTXO) + for i := range utxos { + um[i] = utxos[i] + } + + // First try to find a single UTXO that equal or exceed the given value. + // XXX not random enough + for _, utxo := range um { + if utxo.Value >= amount { + return []*bfgapi.BitcoinUTXO{utxo}, nil + } + } + + return nil, fmt.Errorf( + "address does not have sufficient balance to PoP mine, please send additional funds to continue", + ) +} + +func createTx(l2Keystone *hemi.L2Keystone, btcHeight uint64, utxo *bfgapi.BitcoinUTXO, payToScript []byte, feeAmount int64) (*btcwire.MsgTx, error) { + btx := btcwire.MsgTx{ + Version: 2, + LockTime: uint32(btcHeight), + } + + // Add UTXO as input. + outPoint := btcwire.OutPoint{ + Hash: btcchainhash.Hash(utxo.Hash), + Index: utxo.Index, + } + btx.TxIn = []*btcwire.TxIn{btcwire.NewTxIn(&outPoint, payToScript, nil)} + + // Add output for change as P2PKH. + changeAmount := utxo.Value - feeAmount + btx.TxOut = []*btcwire.TxOut{btcwire.NewTxOut(changeAmount, payToScript)} + + // Add PoP TX using OP_RETURN output. + aks := hemi.L2KeystoneAbbreviate(*l2Keystone) + popTx := pop.TransactionL2{L2Keystone: aks} + popTxOpReturn, err := popTx.EncodeToOpReturn() + if err != nil { + return nil, fmt.Errorf("failed to encode PoP transaction: %v", err) + } + btx.TxOut = append(btx.TxOut, btcwire.NewTxOut(0, popTxOpReturn)) + + return &btx, nil +} + +// XXX this function is not right. Clean it up and ensure we make this in at +// least 2 functions. This needs to create and sign a tx, and then broadcast +// seperately. Also utxo picker needs to be fixed. Don't return a fake utxo +// etc. Fix fee estimation. +func (m *Miner) mineKeystone(ctx context.Context, ks *hemi.L2Keystone) error { + log.Infof("Broadcasting PoP transaction to Bitcoin...") + + btcHeight, err := m.bitcoinHeight(ctx) + if err != nil { + return fmt.Errorf("failed to get Bitcoin height: %v", err) + } + + payToScript, err := btctxscript.PayToAddrScript(m.btcAddress) + if err != nil { + return fmt.Errorf("failed to get pay to address script: %v", err) + } + if len(payToScript) != 25 { + return fmt.Errorf("incorrect length for pay to public key script (%d != 25)", len(payToScript)) + } + scriptHash := btcchainhash.Hash(sha256.Sum256(payToScript)) + + // Estimate BTC fees. + txLen := 285 // XXX for now all transactions are the same size + feePerKB := 1024 + feeAmount := (int64(txLen) * int64(feePerKB)) / 1024 + + // Check balance. + confirmed, unconfirmed, err := m.bitcoinBalance(ctx, scriptHash[:]) + if err != nil { + return fmt.Errorf("failed to get Bitcoin balance: %v", err) + } + log.Tracef("Bitcoin balance for miner is: %v confirmed, %v unconfirmed", confirmed, unconfirmed) + + // Find UTXOs for inputs. + log.Tracef("Looking for UTXOs for script hash %v", scriptHash) + utxos, err := m.bitcoinUTXOs(ctx, scriptHash[:]) + if err != nil { + return fmt.Errorf("failed to get Bitcoin UTXOs: %v", err) + } + + log.Tracef("Found %d UTXOs at Bitcoin height %d", len(utxos), btcHeight) + + if len(utxos) == 0 { + return errors.New("could not find any utxos") + } + + utxos, err = pickUTXOs(utxos, feeAmount) + if err != nil { + return fmt.Errorf("failed to pick UTXOs: %v", err) + } + + if len(utxos) != 1 { + return errors.New("unable to create PoP transaction with a single UTXO") + } + + // Build transaction. + btx, err := createTx(ks, btcHeight, utxos[0], payToScript, feeAmount) + if err != nil { + return err + } + + // Sign input. + err = bitcoin.SignTx(btx, payToScript, m.btcPrivateKey, m.btcPublicKey) + if err != nil { + return err + } + + // broadcast tx + var buf bytes.Buffer + if err := btx.Serialize(&buf); err != nil { + return fmt.Errorf("failed to serialize Bitcoin transaction: %v", err) + } + txb := buf.Bytes() + + log.Tracef("Broadcasting Bitcoin transaction %x", txb) + + txh, err := m.bitcoinBroadcast(ctx, txb) + if err != nil { + return fmt.Errorf("failed to broadcast PoP transaction: %v", err) + } + txHash, err := btcchainhash.NewHash(txh) + if err != nil { + return fmt.Errorf("failed to create BTC hash from transaction hash: %v", err) + } + + log.Infof("Successfully broadcast PoP transaction to Bitcoin with TX hash %v", txHash) + + return nil +} + +func (m *Miner) Ping(ctx context.Context, timestamp int64) (*bfgapi.PingResponse, error) { + res, err := m.callBFG(ctx, m.requestTimeout, &bfgapi.PingRequest{ + Timestamp: timestamp, + }) + if err != nil { + return nil, fmt.Errorf("ping: %w", err) + } + + pr, ok := res.(*bfgapi.PingResponse) + if !ok { + return nil, fmt.Errorf("not a PingResponse: %T", res) + } + + return pr, nil +} + +func (m *Miner) L2Keystones(ctx context.Context, count uint64) (*bfgapi.L2KeystonesResponse, error) { + res, err := m.callBFG(ctx, m.requestTimeout, &bfgapi.L2KeystonesRequest{ + NumL2Keystones: count, + }) + if err != nil { + return nil, fmt.Errorf("l2keystones: %w", err) + } + + kr, ok := res.(*bfgapi.L2KeystonesResponse) + if !ok { + return nil, fmt.Errorf("not a L2KeystonesResponse: %T", res) + } + + return kr, nil +} + +func (m *Miner) BitcoinBalance(ctx context.Context, scriptHash string) (*bfgapi.BitcoinBalanceResponse, error) { + if scriptHash[0:2] == "0x" || scriptHash[0:2] == "0X" { + scriptHash = scriptHash[2:] + } + sh, err := hex.DecodeString(scriptHash) + if err != nil { + return nil, fmt.Errorf("bitcoinBalance: %w", err) + } + res, err := m.callBFG(ctx, m.requestTimeout, &bfgapi.BitcoinBalanceRequest{ + ScriptHash: sh, + }) + if err != nil { + return nil, fmt.Errorf("bitcoinBalance: %w", err) + } + + br, ok := res.(*bfgapi.BitcoinBalanceResponse) + if !ok { + return nil, fmt.Errorf("not a BitcoinBalanceResponse: %T", res) + } + + return br, nil +} + +func (m *Miner) BitcoinInfo(ctx context.Context) (*bfgapi.BitcoinInfoResponse, error) { + res, err := m.callBFG(ctx, m.requestTimeout, &bfgapi.BitcoinInfoRequest{}) + if err != nil { + return nil, fmt.Errorf("bitcoinInfo: %w", err) + } + + ir, ok := res.(*bfgapi.BitcoinInfoResponse) + if !ok { + return nil, fmt.Errorf("not a BitcoinInfoResponse: %T", res) + } + + return ir, nil +} + +func (m *Miner) BitcoinUTXOs(ctx context.Context, scriptHash string) (*bfgapi.BitcoinUTXOsResponse, error) { + if scriptHash[0:2] == "0x" || scriptHash[0:2] == "0X" { + scriptHash = scriptHash[2:] + } + sh, err := hex.DecodeString(scriptHash) + if err != nil { + return nil, fmt.Errorf("bitcoinBalance: %w", err) + } + res, err := m.callBFG(ctx, m.requestTimeout, &bfgapi.BitcoinUTXOsRequest{ + ScriptHash: sh, + }) + if err != nil { + return nil, fmt.Errorf("bitcoinUTXOs: %w", err) + } + + ir, ok := res.(*bfgapi.BitcoinUTXOsResponse) + if !ok { + return nil, fmt.Errorf("not a BitcoinUTXOsResponse: %T", res) + } + + return ir, nil +} + +func (m *Miner) mine(ctx context.Context) { + defer m.wg.Done() + for { + select { + case <-ctx.Done(): + return + case ks := <-m.keystoneCh: + log.Tracef("Received new keystone header for mining with height %v...", ks.L2BlockNumber) + if err := m.mineKeystone(ctx, ks); err != nil { + log.Errorf("Failed to mine keystone: %v", err) + } + } + } +} + +func (m *Miner) queueKeystoneForMining(keystone *hemi.L2Keystone) { + select { + case m.keystoneCh <- keystone: + default: + } +} + +func sortL2KeystonesByL2BlockNumberAsc(a, b hemi.L2Keystone) int { + return cmp.Compare(a.L2BlockNumber, b.L2BlockNumber) +} + +func (m *Miner) processReceivedKeystones(ctx context.Context, l2Keystones []hemi.L2Keystone) { + slices.SortFunc(l2Keystones, sortL2KeystonesByL2BlockNumberAsc) + + for _, kh := range l2Keystones { + log.Infof( + "checking keystone received with height %d against last keystone %s", + kh.L2BlockNumber, + func() string { + if m.lastKeystone == nil { + return "nil" + } + + return fmt.Sprintf("%d", m.lastKeystone.L2BlockNumber) + }(), + ) + if m.lastKeystone == nil || kh.L2BlockNumber > m.lastKeystone.L2BlockNumber { + log.Infof("Got new last keystone header with height %v", kh.L2BlockNumber) + + // copy L2Keystone to a tmp variable so the value doesn't get + // ovewritten on next iteration. otherwise lastKeystone always + // points to the latest kh + tmp := kh + m.lastKeystone = &tmp + + m.queueKeystoneForMining(&tmp) + } + } +} + +func (m *Miner) callBFG(parrentCtx context.Context, timeout time.Duration, msg any) (any, error) { + log.Tracef("callBFG %T", msg) + defer log.Tracef("callBFG exit %T", msg) + + bc := bfgCmd{ + msg: msg, + ch: make(chan any), + } + + ctx, cancel := context.WithTimeout(parrentCtx, timeout) + defer cancel() + + // attempt to send + select { + case <-ctx.Done(): + return nil, ctx.Err() + case m.bfgCmdCh <- bc: + default: + return nil, fmt.Errorf("bfg command queue full") + } + + // Wait for response + select { + case <-ctx.Done(): + return nil, ctx.Err() + case payload := <-bc.ch: + if err, ok := payload.(error); ok { + return nil, err + } + return payload, nil + } + + // Won't get here +} + +func (m *Miner) checkForKeystones(ctx context.Context) error { + log.Tracef("Checking for new keystone headers...") + + ghkr := &bfgapi.L2KeystonesRequest{ + NumL2Keystones: 3, // XXX this needs to be a bit smarter, do this based on some sort of time calculation. Do keep it simple, we don't need keystones that are older than let's say, 30 minbutes. + } + + res, err := m.callBFG(ctx, m.requestTimeout, ghkr) + if err != nil { + return err + } + + ghkrResp, ok := res.(*bfgapi.L2KeystonesResponse) + if !ok { + return fmt.Errorf("not an L2KeystonesResponse") + } + + log.Tracef("Got response with %v keystones", len(ghkrResp.L2Keystones)) + + m.processReceivedKeystones(ctx, ghkrResp.L2Keystones) + + return nil +} + +func (m *Miner) running() bool { + m.mtx.Lock() + defer m.mtx.Unlock() + return m.isRunning +} + +func (m *Miner) testAndSetRunning(b bool) bool { + m.mtx.Lock() + defer m.mtx.Unlock() + old := m.isRunning + m.isRunning = b + return old != m.isRunning +} + +func (m *Miner) promRunning() float64 { + r := m.running() + if r { + return 1 + } + return 0 +} + +func handle(service string, mux *http.ServeMux, pattern string, handler func(http.ResponseWriter, *http.Request)) { + mux.HandleFunc(pattern, handler) + log.Infof("handle (%v): %v", service, pattern) +} + +func (m *Miner) handleBFGCallCompletion(parrentCtx context.Context, conn *protocol.Conn, bc bfgCmd) { + log.Tracef("handleBFGCallCompletion") + defer log.Tracef("handleBFGCallCompletion exit") + + ctx, cancel := context.WithTimeout(parrentCtx, m.requestTimeout) + defer cancel() + + log.Tracef("handleBFGCallCompletion: %v", spew.Sdump(bc.msg)) + + _, _, payload, err := bfgapi.Call(ctx, conn, bc.msg) + if err != nil { + log.Debugf("handleBFGCallCompletion %T: %v", bc.msg, err) + select { + case <-ctx.Done(): + bc.ch <- ctx.Err() + case bc.ch <- err: + default: + } + } + select { + case bc.ch <- payload: + log.Tracef("handleBFGCallCompletion returned: %v", spew.Sdump(payload)) + default: + } +} + +func (m *Miner) handleBFGWebsocketRead(ctx context.Context, conn *protocol.Conn) error { + defer m.bfgWg.Done() + + log.Tracef("handleBFGWebsocketRead") + defer log.Tracef("handleBFGWebsocketRead exit") + for { + cmd, rid, payload, err := bfgapi.ReadConn(ctx, conn) + if err != nil { + // XXX kinda don't want to do thi here + if errors.Is(err, protocol.PublicKeyAuthError) { + return err + } + + // See if we were terminated + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(m.holdoffTimeout): + } + // XXX this is too noisy + log.Errorf("Retrying connectiong to BFG") + continue + } + switch cmd { + case bfgapi.CmdPingRequest: + p := payload.(*bfgapi.PingRequest) + response := &bfgapi.PingResponse{ + OriginTimestamp: p.Timestamp, + Timestamp: time.Now().Unix(), + } + // XXX WriteConn ?? + if err := bfgapi.Write(ctx, conn, rid, response); err != nil { + log.Errorf("handleBFGWebsocketRead write: %v", + err) + } + case bfgapi.CmdL2KeystonesNotification: + go func() { + if err := m.checkForKeystones(ctx); err != nil { + log.Errorf("error checking for keystones: %s", err) + } + }() + default: + return fmt.Errorf("unknown command: %v", cmd) + } + } +} + +func (m *Miner) handleBFGWebsocketCall(ctx context.Context, conn *protocol.Conn) { + defer m.bfgWg.Done() + + log.Tracef("handleBFGWebsocketCall") + defer log.Tracef("handleBFGWebsocketCall exit") + for { + select { + case <-ctx.Done(): + return + case bc := <-m.bfgCmdCh: + go m.handleBFGCallCompletion(ctx, conn, bc) + } + } +} + +func (m *Miner) connectBFG(pctx context.Context) error { + log.Tracef("connectBFG") + defer log.Tracef("connectBFG exit") + + var ( + err error + authenticator protocol.Authenticator + conn *protocol.Conn + ) + + authenticator, err = auth.NewSecp256k1AuthClient(m.btcPrivateKey) + if err != nil { + return err + } + + conn, err = protocol.NewConn(m.cfg.BFGWSURL, authenticator) + if err != nil { + return err + } + + ctx, cancel := context.WithCancel(pctx) + defer cancel() + + err = conn.Connect(ctx) + if err != nil { + return err + } + + m.bfgWg.Add(1) + go m.handleBFGWebsocketCall(ctx, conn) + + // XXX ugh + rWSCh := make(chan error) + m.bfgWg.Add(1) + go func() { + rWSCh <- m.handleBFGWebsocketRead(ctx, conn) + }() + + select { + case <-ctx.Done(): + err = ctx.Err() + case err = <-rWSCh: + } + cancel() + + // Wait for exit + log.Debugf("Connected to BFG: %s", m.cfg.BFGWSURL) + m.bfgWg.Wait() + + return nil +} + +func (m *Miner) bfg(ctx context.Context) error { + defer m.wg.Done() + + log.Tracef("bfg") + defer log.Tracef("bfg exit") + + for { + if err := m.connectBFG(ctx); err != nil { + log.Debugf("connectBFG: %v", err) + + if errors.Is(err, protocol.PublicKeyAuthError) { + return err + } + } + + // See if we were terminated + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(m.holdoffTimeout): + } + + log.Debugf("Reconnecting to: %v", m.cfg.BFGWSURL) + } +} + +func (m *Miner) Run(pctx context.Context) error { + if !m.testAndSetRunning(true) { + return fmt.Errorf("popmd already running") + } + defer m.testAndSetRunning(false) + + ctx, cancel := context.WithCancel(pctx) + defer cancel() + + // Prometheus + if m.cfg.PrometheusListenAddress != "" { + if err := m.handlePrometheus(ctx); err != nil { + return fmt.Errorf("handlePrometheus: %w", err) + } + } + + log.Infof("Starting PoP miner with BTC address %v (public key %x)", + m.btcAddress.EncodeAddress(), m.btcPublicKey.SerializeCompressed()) + + bfgErrCh := make(chan error) + m.wg.Add(1) + go func() { + bfgErrCh <- m.bfg(ctx) + }() + + m.wg.Add(1) + go m.mine(ctx) + + var err error + select { + case <-ctx.Done(): + err = ctx.Err() + case err = <-bfgErrCh: + } + cancel() + + log.Infof("pop miner service shutting down") + + m.wg.Wait() + log.Infof("pop miner service clean shutdown") + + return err +} diff --git a/service/popm/popm_test.go b/service/popm/popm_test.go new file mode 100644 index 00000000..3f312918 --- /dev/null +++ b/service/popm/popm_test.go @@ -0,0 +1,846 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package popm + +import ( + "bytes" + "context" + "encoding/hex" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + btcchainhash "github.com/btcsuite/btcd/chaincfg/chainhash" + btctxscript "github.com/btcsuite/btcd/txscript" + btcwire "github.com/btcsuite/btcd/wire" + dcrsecp256k1 "github.com/decred/dcrd/dcrec/secp256k1/v4" + dcrecdsa "github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa" + "github.com/go-test/deep" + "nhooyr.io/websocket" + + "github.com/hemilabs/heminetwork/api/auth" + "github.com/hemilabs/heminetwork/api/bfgapi" + "github.com/hemilabs/heminetwork/api/protocol" + "github.com/hemilabs/heminetwork/bitcoin" + "github.com/hemilabs/heminetwork/hemi" + "github.com/hemilabs/heminetwork/hemi/pop" +) + +const ( + EventConnected = "event_connected" +) + +func TestBTCPrivateKeyFromHex(t *testing.T) { + tests := []struct { + input string + want []byte + }{ + { + input: "0000000000000000000000000000000000000000000000000000000000000001", + want: []byte{ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + }, + }, + { + input: "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364140", + want: []byte{ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, + 0xba, 0xae, 0xdc, 0xe6, 0xaf, 0x48, 0xa0, 0x3b, + 0xbf, 0xd2, 0x5e, 0x8c, 0xd0, 0x36, 0x41, 0x40, + }, + }, + { + input: "0000000000000000000000000000000000000000000000000000000000000000", + want: nil, + }, + { + input: "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141", + want: nil, + }, + { + input: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + want: nil, + }, + } + for i, test := range tests { + got, err := bitcoin.PrivKeyFromHexString(test.input) + switch { + case test.want == nil && err == nil: + t.Errorf("Test %d - succeeded, want error", i) + case test.want != nil && err != nil: + t.Errorf("Test %d - failed with error: %v", i, err) + case test.want != nil && err == nil: + if !bytes.Equal(got.Serialize(), test.want) { + t.Errorf("Test %d - got private key %x, want %x", i, got.Serialize(), test.want) + } + } + } +} + +func TestNewMiner(t *testing.T) { + cfg := NewDefaultConfig() + cfg.BTCPrivateKey = "ebaaedce6af48a03bbfd25e8cd0364140ebaaedce6af48a03bbfd25e8cd03641" + + m, err := NewMiner(cfg) + if err != nil { + t.Fatalf("Failed to create new miner: %v", err) + } + + got, want := m.btcAddress.EncodeAddress(), "mnwAf6TWJK1MjbKkK9rq8MGvWBRUuo3PJk" + if got != want { + t.Errorf("Got BTC pubkey hash address %q, want %q", got, want) + } + got, want = m.btcAddress.String(), "mnwAf6TWJK1MjbKkK9rq8MGvWBRUuo3PJk" + if got != want { + t.Errorf("Got BTC pubkey hash address %q, want %q", got, want) + } +} + +// TestProcessReceivedKeystones ensures that we store the latest keystone +// correctly as well as data stored in slices within the struct +func TestProcessReceivedKeystones(t *testing.T) { + firstBatchOfL2Keystones := []hemi.L2Keystone{ + { + L2BlockNumber: 3, + EPHash: []byte{3}, + }, + { + L2BlockNumber: 2, + EPHash: []byte{2}, + }, + { + L2BlockNumber: 1, + EPHash: []byte{1}, + }, + } + + secondBatchOfL2Keystones := []hemi.L2Keystone{ + { + L2BlockNumber: 6, + EPHash: []byte{6}, + }, + { + L2BlockNumber: 5, + EPHash: []byte{5}, + }, + { + L2BlockNumber: 4, + EPHash: []byte{4}, + }, + } + + miner := Miner{} + + miner.processReceivedKeystones(context.Background(), firstBatchOfL2Keystones) + diff := deep.Equal(*miner.lastKeystone, hemi.L2Keystone{ + L2BlockNumber: 3, + EPHash: []byte{3}, + }) + + if len(diff) != 0 { + t.Fatalf("unexpected diff: %v", diff) + } + + miner.processReceivedKeystones(context.Background(), secondBatchOfL2Keystones) + diff = deep.Equal(*miner.lastKeystone, hemi.L2Keystone{ + L2BlockNumber: 6, + EPHash: []byte{6}, + }) + + if len(diff) != 0 { + t.Fatalf("unexpected diff: %v", diff) + } +} + +func TestCreateTxVersion2(t *testing.T) { + l2Keystone := hemi.L2Keystone{} + + utxo := bfgapi.BitcoinUTXO{ + Hash: make([]byte, 32), + } + + mockPayToScript := []byte{} + + btx, err := createTx(&l2Keystone, 1, &utxo, mockPayToScript, 1) + if err != nil { + t.Fatal(err) + } + + if btx.Version != 2 { + t.Fatalf("the tx version must be 2, received %d", btx.Version) + } +} + +func TestCreateTxLockTime(t *testing.T) { + l2Keystone := hemi.L2Keystone{} + + utxo := bfgapi.BitcoinUTXO{ + Hash: make([]byte, 32), + } + + mockPayToScript := []byte{} + + var height uint64 = 99 + + btx, err := createTx(&l2Keystone, height, &utxo, mockPayToScript, 1) + if err != nil { + t.Fatal(err) + } + + if uint64(btx.LockTime) != height { + t.Fatalf("received unexpected lock time %d", btx.LockTime) + } +} + +func TestCreateTxTxIn(t *testing.T) { + l2Keystone := hemi.L2Keystone{} + + utxo := bfgapi.BitcoinUTXO{ + Hash: make([]byte, 32), + Index: 5, + Value: 10, + } + + copy(utxo.Hash, []byte{1, 2, 3}) + + mockPayToScript := []byte{4, 5, 6} + + var height uint64 = 99 + + var feeAmount int64 = 10 + + btx, err := createTx(&l2Keystone, height, &utxo, mockPayToScript, feeAmount) + if err != nil { + t.Fatal(err) + } + + outPoint := btcwire.OutPoint{ + Hash: btcchainhash.Hash(utxo.Hash), + Index: utxo.Index, + } + + expectedTxIn := []*btcwire.TxIn{btcwire.NewTxIn(&outPoint, mockPayToScript, nil)} + + diff := deep.Equal(expectedTxIn, btx.TxIn) + if len(diff) != 0 { + t.Fatalf("got unexpected diff %s", diff) + } +} + +func TestCreateTxTxOutPayTo(t *testing.T) { + l2Keystone := hemi.L2Keystone{} + + utxo := bfgapi.BitcoinUTXO{ + Hash: make([]byte, 32), + Index: 5, + Value: 10, + } + + copy(utxo.Hash, []byte{1, 2, 3}) + + mockPayToScript := []byte{4, 5, 6} + + var height uint64 = 99 + + var feeAmount int64 = 10 + + btx, err := createTx(&l2Keystone, height, &utxo, mockPayToScript, feeAmount) + if err != nil { + t.Fatal(err) + } + + expectexTxOut := btcwire.NewTxOut(utxo.Value-feeAmount, mockPayToScript) + diff := deep.Equal(expectexTxOut, btx.TxOut[0]) + if len(diff) != 0 { + t.Fatalf("got unexpected diff %s", diff) + } +} + +func TestCreateTxTxOutPopTx(t *testing.T) { + l2Keystone := hemi.L2Keystone{} + + utxo := bfgapi.BitcoinUTXO{ + Hash: make([]byte, 32), + Index: 5, + Value: 10, + } + + copy(utxo.Hash, []byte{1, 2, 3}) + + mockPayToScript := []byte{4, 5, 6} + + var height uint64 = 99 + + var feeAmount int64 = 10 + + btx, err := createTx(&l2Keystone, height, &utxo, mockPayToScript, feeAmount) + if err != nil { + t.Fatal(err) + } + + aks := hemi.L2KeystoneAbbreviate(l2Keystone) + popTx := pop.TransactionL2{L2Keystone: aks} + popTxOpReturn, err := popTx.EncodeToOpReturn() + if err != nil { + t.Fatalf("failed to encode PoP transaction: %v", err) + } + + expectexTxOut := btcwire.NewTxOut(0, popTxOpReturn) + diff := deep.Equal(expectexTxOut, btx.TxOut[1]) + if len(diff) != 0 { + t.Fatalf("got unexpected diff %s", diff) + } +} + +func TestSignTx(t *testing.T) { + type TestTableItem struct { + name string + expectedError error + l2Keystone hemi.L2Keystone + utxo bfgapi.BitcoinUTXO + utxoHash []byte + payToScript []byte + height uint64 + feeAmount int64 + keyPair func() (*dcrsecp256k1.PrivateKey, *dcrsecp256k1.PublicKey) + } + + testTable := []TestTableItem{ + { + name: "Test Sign Tx", + l2Keystone: hemi.L2Keystone{}, + utxo: bfgapi.BitcoinUTXO{ + Hash: make([]byte, 32), + Index: 5, + Value: 10, + }, + utxoHash: []byte{1, 2, 3}, + payToScript: []byte{4, 5, 6, 7, 8}, + height: 99, + feeAmount: 10, + keyPair: func() (*dcrsecp256k1.PrivateKey, *dcrsecp256k1.PublicKey) { + privateKey, err := dcrsecp256k1.GeneratePrivateKey() + if err != nil { + t.Fatal(err) + } + + publicKey := privateKey.PubKey() + + return privateKey, publicKey + }, + }, + { + name: "Test Sign Tx key mismatch", + l2Keystone: hemi.L2Keystone{}, + utxo: bfgapi.BitcoinUTXO{ + Hash: make([]byte, 32), + Index: 5, + Value: 10, + }, + utxoHash: []byte{1, 2, 3}, + payToScript: []byte{4, 5, 6, 7, 8}, + height: 99, + feeAmount: 10, + keyPair: func() (*dcrsecp256k1.PrivateKey, *dcrsecp256k1.PublicKey) { + privateKey, err := dcrsecp256k1.GeneratePrivateKey() + if err != nil { + t.Fatal(err) + } + + otherPrivateKey, err := dcrsecp256k1.GeneratePrivateKey() + if err != nil { + t.Fatal(err) + } + + publicKey := otherPrivateKey.PubKey() + + return privateKey, publicKey + }, + expectedError: errors.New("wrong public key for private key"), + }, + } + + for _, testTableItem := range testTable { + t.Run(testTableItem.name, func(t *testing.T) { + copy(testTableItem.utxo.Hash, testTableItem.utxoHash) + btx, err := createTx( + &testTableItem.l2Keystone, + testTableItem.height, + &testTableItem.utxo, + testTableItem.payToScript, + testTableItem.feeAmount, + ) + if err != nil { + t.Fatal(err) + } + + sigHash, err := btctxscript.CalcSignatureHash( + testTableItem.payToScript, + btctxscript.SigHashAll, + btx, + 0, + ) + if err != nil { + t.Fatalf("failed to calculate signature hash: %v", err) + } + + privateKey, publicKey := testTableItem.keyPair() + + err = bitcoin.SignTx(btx, testTableItem.payToScript, privateKey, publicKey) + + if testTableItem.expectedError != nil { + if err == nil { + t.Fatal("expected error, received nil") + } else { + if testTableItem.expectedError.Error() != err.Error() { + t.Fatalf("unexpected error: %s", err) + } + return + } + } else if err != nil { + t.Fatal(err) + } + + pubKeyBytes := publicKey.SerializeCompressed() + sig := dcrecdsa.Sign(privateKey, sigHash) + sigBytes := append(sig.Serialize(), byte(btctxscript.SigHashAll)) + sigScript, err := btctxscript. + NewScriptBuilder().AddData(sigBytes).AddData(pubKeyBytes).Script() + if err != nil { + t.Fatalf("failed to build signature script: %v", err) + } + + diff := deep.Equal(sigScript, btx.TxIn[0].SignatureScript) + if len(diff) != 0 { + t.Fatalf("unexpected diff %s", diff) + } + }) + } +} + +func TestSignTxDifferingPubPrivKeys(t *testing.T) { + l2Keystone := hemi.L2Keystone{} + + utxo := bfgapi.BitcoinUTXO{ + Hash: make([]byte, 32), + Index: 5, + Value: 10, + } + + copy(utxo.Hash, []byte{1, 2, 3}) + + mockPayToScript := []byte("something") + + var height uint64 = 99 + + var feeAmount int64 = 10 + + btx, err := createTx(&l2Keystone, height, &utxo, mockPayToScript, feeAmount) + if err != nil { + t.Fatal(err) + } + + privateKey, err := dcrsecp256k1.GeneratePrivateKey() + if err != nil { + t.Fatal(err) + } + + otherPrivateKey, err := dcrsecp256k1.GeneratePrivateKey() + if err != nil { + t.Fatal(err) + } + + publicKey := otherPrivateKey.PubKey() + + err = bitcoin.SignTx(btx, mockPayToScript, privateKey, publicKey) + if err == nil || err.Error() != "wrong public key for private key" { + t.Fatalf("unexpected error %s", err) + } +} + +// TestProcessReceivedInAscOrder ensures that we sort and process the latest +// N (3) L2Keystones in ascending order to handle the oldest first +func TestProcessReceivedInAscOrder(t *testing.T) { + firstBatchOfL2Keystones := []hemi.L2Keystone{ + { + L2BlockNumber: 3, + EPHash: []byte{3}, + }, + { + L2BlockNumber: 2, + EPHash: []byte{2}, + }, + { + L2BlockNumber: 1, + EPHash: []byte{1}, + }, + } + + miner, err := NewMiner(&Config{ + BTCPrivateKey: "ebaaedce6af48a03bbfd25e8cd0364140ebaaedce6af48a03bbfd25e8cd03641", + BTCChainName: "testnet3", + }) + if err != nil { + t.Fatal(err) + } + miner.processReceivedKeystones(context.Background(), firstBatchOfL2Keystones) + + receivedKeystones := []hemi.L2Keystone{} + + for { + select { + case l2Keystone := <-miner.keystoneCh: + receivedKeystones = append(receivedKeystones, *l2Keystone) + continue + default: + break + } + break + } + + diff := deep.Equal(firstBatchOfL2Keystones, receivedKeystones) + if len(diff) != 0 { + t.Fatalf("received unexpected diff: %s", diff) + } +} + +func TestConnectToBFGAndPerformMineWithAuth(t *testing.T) { + privateKey, err := dcrsecp256k1.GeneratePrivateKey() + if err != nil { + t.Fatal(err) + } + + publicKey := hex.EncodeToString(privateKey.PubKey().SerializeCompressed()) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + server, msgCh, cleanup := createMockBFG(ctx, t, []string{publicKey}) + defer cleanup() + + go func() { + miner, err := NewMiner(&Config{ + BFGWSURL: server.URL + bfgapi.RouteWebsocketPublic, + BTCChainName: "testnet3", + BTCPrivateKey: hex.EncodeToString(privateKey.Serialize()), + }) + if err != nil { + panic(err) + } + + err = miner.Run(ctx) + if err != nil && err != context.Canceled { + panic(err) + } + }() + + // we can't guarantee order here, so test that we get all expected messages + // from popm within the timeout + + messagesReceived := make(map[string]bool) + + messagesExpected := []protocol.Command{ + EventConnected, + bfgapi.CmdL2KeystonesRequest, + bfgapi.CmdBitcoinInfoRequest, + bfgapi.CmdBitcoinBalanceRequest, + bfgapi.CmdBitcoinUTXOsRequest, + bfgapi.CmdBitcoinBroadcastRequest, + } + + for { + select { + case msg := <-msgCh: + t.Logf("received message %v", msg) + messagesReceived[msg] = true + case <-ctx.Done(): + if ctx.Err() != nil { + t.Fatal(ctx.Err()) + } + } + missing := false + for _, m := range messagesExpected { + if !messagesReceived[fmt.Sprintf("%s", m)] { + t.Logf("still missing message %v", m) + missing = true + } + } + if missing == false { + break + } + } +} + +func TestConnectToBFGAndPerformMine(t *testing.T) { + privateKey, err := dcrsecp256k1.GeneratePrivateKey() + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + server, msgCh, cleanup := createMockBFG(ctx, t, []string{}) + defer cleanup() + + go func() { + miner, err := NewMiner(&Config{ + BFGWSURL: server.URL + bfgapi.RouteWebsocketPublic, + BTCChainName: "testnet3", + BTCPrivateKey: hex.EncodeToString(privateKey.Serialize()), + }) + if err != nil { + panic(err) + } + + err = miner.Run(ctx) + if err != nil && err != context.Canceled { + panic(err) + } + }() + + // we can't guarantee order here, so test that we get all expected messages + // from popm within the timeout + + messagesReceived := make(map[string]bool) + + messagesExpected := []protocol.Command{ + EventConnected, + bfgapi.CmdL2KeystonesRequest, + bfgapi.CmdBitcoinInfoRequest, + bfgapi.CmdBitcoinBalanceRequest, + bfgapi.CmdBitcoinUTXOsRequest, + bfgapi.CmdBitcoinBroadcastRequest, + } + + for { + select { + case msg := <-msgCh: + t.Logf("received message %v", msg) + messagesReceived[msg] = true + case <-ctx.Done(): + if ctx.Err() != nil { + t.Fatal(ctx.Err()) + } + } + missing := false + for _, m := range messagesExpected { + if !messagesReceived[fmt.Sprintf("%s", m)] { + t.Logf("still missing message %v", m) + missing = true + } + } + if missing == false { + break + } + } +} + +func TestConnectToBFGAndPerformMineWithAuthError(t *testing.T) { + privateKey, err := dcrsecp256k1.GeneratePrivateKey() + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + server, msgCh, cleanup := createMockBFG(ctx, t, []string{"incorrect"}) + defer cleanup() + + miner, err := NewMiner(&Config{ + BFGWSURL: server.URL + bfgapi.RouteWebsocketPublic, + BTCChainName: "testnet3", + BTCPrivateKey: hex.EncodeToString(privateKey.Serialize()), + }) + if err != nil { + t.Fatal(err) + } + + go func() { + for { + select { + case <-ctx.Done(): + return + case <-msgCh: + } + } + }() + if err := miner.Run(ctx); err != nil { + for err != nil { + if errors.Is(err, protocol.PublicKeyAuthError) { + return + } + err = errors.Unwrap(err) + } + t.Fatalf("want protocol.PublicKeyAuthError, got: %v", err) + } +} + +func createMockBFG(ctx context.Context, t *testing.T, publicKeys []string) (*httptest.Server, chan string, func()) { + msgCh := make(chan string) + + handler := func(w http.ResponseWriter, r *http.Request) { + c, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + InsecureSkipVerify: true, + }) + if err != nil { + panic(err) + } + + defer func() { + if err := c.Close(websocket.StatusNormalClosure, ""); err != nil { + t.Logf("error closing websocket: %s", err) + } + }() + + conn := protocol.NewWSConn(c) + + go func() { + select { + case msgCh <- EventConnected: + case <-ctx.Done(): + return + } + }() + + authServer, err := auth.NewSecp256k1AuthServer() + if err != nil { + t.Fatalf("could not create auth server: %s", err) + } + + if err := authServer.HandshakeServer(r.Context(), conn); err != nil { + t.Fatalf("error with server handshake: %s", err) + } + + publicKey := authServer.RemotePublicKey().SerializeCompressed() + + publicKeyEncoded := hex.EncodeToString(publicKey) + + log.Tracef("successful handshake with public key: %s", publicKeyEncoded) + if len(publicKeys) > 0 { + + found := false + for _, v := range publicKeys { + if publicKeyEncoded == v { + found = true + } + } + + if !found { + c.Close(protocol.PublicKeyAuthError.Code, protocol.PublicKeyAuthError.Reason) + return + } + + log.Infof("authorized connection with public key: %s", publicKeyEncoded) + } + + if err := bfgapi.Write(ctx, conn, "someid", bfgapi.PingRequest{}); err != nil { + panic(err) + } + + if err := bfgapi.Write(ctx, conn, "someid", bfgapi.L2KeystonesNotification{}); err != nil { + panic(err) + } + + for { + command, id, _, err := bfgapi.Read(ctx, conn) + if err != nil { + if !errors.Is(ctx.Err(), context.Canceled) { + panic(err) + } + + return + } + + t.Logf("command is %s", command) + + go func() { + select { + case msgCh <- fmt.Sprintf("%s", command): + case <-ctx.Done(): + return + } + }() + + if command == bfgapi.CmdL2KeystonesRequest { + if err := bfgapi.Write(ctx, conn, id, bfgapi.L2KeystonesResponse{ + L2Keystones: []hemi.L2Keystone{ + { + L2BlockNumber: 100, + }, + }, + }); err != nil { + if !errors.Is(ctx.Err(), context.Canceled) { + panic(err) + } + } + } + + if command == bfgapi.CmdBitcoinInfoRequest { + if err := bfgapi.Write(ctx, conn, id, bfgapi.BitcoinInfoResponse{ + Height: 809, + }); err != nil { + if !errors.Is(ctx.Err(), context.Canceled) { + panic(err) + } + } + } + + if command == bfgapi.CmdBitcoinBalanceRequest { + if err := bfgapi.Write(ctx, conn, id, bfgapi.BitcoinBalanceResponse{ + Unconfirmed: 809, + }); err != nil { + if !errors.Is(ctx.Err(), context.Canceled) { + panic(err) + } + } + } + + if command == bfgapi.CmdBitcoinUTXOsRequest { + if err := bfgapi.Write(ctx, conn, id, bfgapi.BitcoinUTXOsResponse{ + UTXOs: []*bfgapi.BitcoinUTXO{ + { + Index: 9999, + Value: 999999, + Hash: []byte{ + 2, 1, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 10, 9, 8, + 7, 6, 5, 4, 3, 2, 1, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, + }, + }, + }, + }); err != nil { + if !errors.Is(ctx.Err(), context.Canceled) { + panic(err) + } + } + } + + if command == bfgapi.CmdBitcoinBroadcastRequest { + if err := bfgapi.Write(ctx, conn, id, bfgapi.BitcoinBroadcastResponse{ + TXID: []byte{ + 2, 1, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 10, 9, 8, + 7, 6, 5, 4, 3, 2, 1, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, + }, + }); err != nil { + if !errors.Is(ctx.Err(), context.Canceled) { + panic(err) + } + } + } + + } + } + + s := httptest.NewServer(http.HandlerFunc(handler)) + + return s, msgCh, func() { + s.Close() + } +} diff --git a/service/popm/prometheus.go b/service/popm/prometheus.go new file mode 100644 index 00000000..9c55db99 --- /dev/null +++ b/service/popm/prometheus.go @@ -0,0 +1,43 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +//go:build !js && !wasm + +package popm + +import ( + "context" + "fmt" + + "github.com/prometheus/client_golang/prometheus" + + "github.com/hemilabs/heminetwork/service/deucalion" +) + +func (m *Miner) handlePrometheus(ctx context.Context) error { + d, err := deucalion.New(&deucalion.Config{ + ListenAddress: m.cfg.PrometheusListenAddress, + }) + if err != nil { + return fmt.Errorf("failed to create server: %w", err) + } + cs := []prometheus.Collector{ + prometheus.NewGaugeFunc(prometheus.GaugeOpts{ + Subsystem: promSubsystem, + Name: "running", + Help: "Is pop miner service running.", + }, m.promRunning), + } + m.wg.Add(1) + go func() { + defer m.wg.Done() + if err := d.Run(ctx, cs); err != context.Canceled { + log.Errorf("prometheus terminated with error: %v", err) + return + } + log.Infof("prometheus clean shutdown") + }() + + return nil +} diff --git a/service/popm/prometheus_wasm.go b/service/popm/prometheus_wasm.go new file mode 100644 index 00000000..17d00f7c --- /dev/null +++ b/service/popm/prometheus_wasm.go @@ -0,0 +1,13 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +//go:build js && wasm + +package popm + +import "context" + +func (m *Miner) handlePrometheus(ctx context.Context) error { + return nil +} diff --git a/version/version.go b/version/version.go new file mode 100644 index 00000000..e2b54ba0 --- /dev/null +++ b/version/version.go @@ -0,0 +1,89 @@ +// Copyright (c) 2013-2014 The btcsuite developers +// Copyright (c) 2015-2021 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +package version + +import ( + "bytes" + "fmt" + "strings" +) + +// semverAlphabet is an alphabet of all characters allowed in semver prerelease +// or build metadata identifiers, and the . separator. +const semverAlphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-." + +// Constants defining the application version number. +const ( + Major = 0 + Minor = 1 + Patch = 0 +) + +// Integer is an integer encoding of the major.minor.patch version. +const Integer = 1000000*Major + 10000*Minor + 100*Patch + +// PreRelease contains the prerelease name of the application. It is a variable +// so it can be modified at link time (e.g. +// `-ldflags "-X decred.org/dcrwallet/v4/version.PreRelease=rc1"`). +// It must only contain characters from the semantic version alphabet. +var PreRelease = "pre" + +// BuildMetadata defines additional build metadata. It is modified at link time +// for official releases. It must only contain characters from the semantic +// version alphabet. +var BuildMetadata = "" + +func init() { + if BuildMetadata == "" { + BuildMetadata = vcsCommitID() + } +} + +// String returns the application version as a properly formed string per the +// semantic versioning 2.0.0 spec (https://semver.org/). +func String() string { + // Start with the major, minor, and path versions. + version := fmt.Sprintf("%d.%d.%d", Major, Minor, Patch) + + // Append pre-release version if there is one. The hyphen called for + // by the semantic versioning spec is automatically appended and should + // not be contained in the pre-release string. The pre-release version + // is not appended if it contains invalid characters. + preRelease := normalizeVerString(PreRelease) + if preRelease != "" { + version = version + "-" + preRelease + } + + // Append build metadata if there is any. The plus called for + // by the semantic versioning spec is automatically appended and should + // not be contained in the build metadata string. The build metadata + // string is not appended if it contains invalid characters. + buildMetadata := normalizeVerString(BuildMetadata) + if buildMetadata != "" { + version = version + "+" + buildMetadata + } + + return version +} + +// normalizeVerString returns the passed string stripped of all characters which +// are not valid according to the semantic versioning guidelines for pre-release +// version and build metadata strings. In particular they MUST only contain +// characters in semanticAlphabet. +func normalizeVerString(str string) string { + var buf bytes.Buffer + for _, r := range str { + if strings.ContainsRune(semverAlphabet, r) { + _, err := buf.WriteRune(r) + // Writing to a bytes.Buffer panics on OOM, and all + // errors are unexpected. + if err != nil { + panic(err) + } + } + } + return buf.String() +} diff --git a/version/version_buildinfo.go b/version/version_buildinfo.go new file mode 100644 index 00000000..06a70370 --- /dev/null +++ b/version/version_buildinfo.go @@ -0,0 +1,32 @@ +// Copyright (c) 2021 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +//go:build go1.18 + +package version + +import "runtime/debug" + +func vcsCommitID() string { + bi, ok := debug.ReadBuildInfo() + if !ok { + return "" + } + var vcs, revision string + for _, bs := range bi.Settings { + switch bs.Key { + case "vcs": + vcs = bs.Value + case "vcs.revision": + revision = bs.Value + } + } + if vcs == "" { + return "" + } + if vcs == "git" && len(revision) > 9 { + revision = revision[:9] + } + return revision +} diff --git a/version/version_nobuildinfo.go b/version/version_nobuildinfo.go new file mode 100644 index 00000000..3355972a --- /dev/null +++ b/version/version_nobuildinfo.go @@ -0,0 +1,11 @@ +// Copyright (c) 2021 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. + +//go:build !go1.18 + +package version + +func vcsCommitID() string { + return "" +} diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 00000000..1b93c9eb --- /dev/null +++ b/web/.gitignore @@ -0,0 +1 @@ +webapp diff --git a/web/Makefile b/web/Makefile new file mode 100644 index 00000000..bf700bc9 --- /dev/null +++ b/web/Makefile @@ -0,0 +1,27 @@ +# Copyright (c) 2024 Hemi Labs, Inc. +# Use of this source code is governed by the MIT License, +# which can be found in the LICENSE file. + +GOROOT=$(shell go env GOROOT) +GITVERSION=$(shell git rev-parse --short HEAD) +WEBAPP=webapp + +.PHONY: all clean prepare wasm www + +all: wasm www + +clean: + rm -rf ${WEBAPP} + +prepare: + mkdir -p ${WEBAPP} + +wasm: prepare + GOOS=js GOARCH=wasm go build -trimpath -ldflags "-X main.gitVersion=${GITVERSION}" \ + -o ${WEBAPP}/popminer.wasm ./popminer/popminer.go + +www: prepare + cp www/index.html ${WEBAPP} + cp www/index.js ${WEBAPP} + cp www/popminer.js ${WEBAPP} + cp ${GOROOT}/misc/wasm/wasm_exec.js ${WEBAPP}/wasm_exec.js diff --git a/web/integrationtest/integrationtest.go b/web/integrationtest/integrationtest.go new file mode 100644 index 00000000..37e8e6fb --- /dev/null +++ b/web/integrationtest/integrationtest.go @@ -0,0 +1,40 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "time" +) + +type Response struct { + CurrentTime string +} + +func main() { + port := ":43111" + listen := "localhost" + port + assets := "webapp" + http.Handle("/", http.FileServer(http.Dir(assets))) + fmt.Printf("point browser to: %v\n", listen) + fmt.Printf("serving from : %v\n", assets) + + http.HandleFunc("/test", func(rw http.ResponseWriter, r *http.Request) { + byteArray, err := json.Marshal(Response{ + CurrentTime: time.Now().Format(time.RFC3339), + }) + if err != nil { + fmt.Println(err) + } + rw.Write(byteArray) + }) + + if err := http.ListenAndServe(listen, nil); err != nil { + log.Fatal(err) + } +} diff --git a/web/popminer/popminer.go b/web/popminer/popminer.go new file mode 100644 index 00000000..d5dfb1f6 --- /dev/null +++ b/web/popminer/popminer.go @@ -0,0 +1,512 @@ +// Copyright (c) 2024 Hemi Labs, Inc. +// Use of this source code is governed by the MIT License, +// which can be found in the LICENSE file. + +//go:build js && wasm + +package main + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + "runtime/debug" + "sync" + "syscall/js" + "time" + + "github.com/btcsuite/btcd/btcutil" + btcchaincfg "github.com/btcsuite/btcd/chaincfg" + dcrsecpk256k1 "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/juju/loggo" + + "github.com/hemilabs/heminetwork/ethereum" + "github.com/hemilabs/heminetwork/service/popm" +) + +const ( + logLevel = "INFO" + version = "1.0.0" +) + +// This is used globally +type PopMiner struct { + // Don't like adding these into the object but c'est la wasm + ctx context.Context + cancel context.CancelFunc + miner *popm.Miner + + wg sync.WaitGroup + err error +} + +type DispatchArgs struct { + Name string + Type js.Type +} + +type Dispatch struct { + Call func(js.Value, []js.Value) (any, error) + Required []DispatchArgs +} + +func wasmPing(this js.Value, args []js.Value) (any, error) { + log.Tracef("wasmPing") + defer log.Tracef("wasmPing exit") + + message := args[0].Get("message").String() + message += " response" + + return map[string]any{"response": message}, nil +} + +func generateKey(this js.Value, args []js.Value) (any, error) { + log.Tracef("generatekey") + defer log.Tracef("generatekey exit") + + net := args[0].Get("network").String() + var ( + btcChainParams *btcchaincfg.Params + netNormalized string + ) + switch net { + case "devnet", "testnet3", "testnet": + btcChainParams = &btcchaincfg.TestNet3Params + netNormalized = "testnet3" + case "mainnet": + btcChainParams = &btcchaincfg.MainNetParams + netNormalized = "mainnet" + default: + return nil, fmt.Errorf("invalid network: %v", net) + } + privKey, err := dcrsecpk256k1.GeneratePrivateKey() + if err != nil { + return nil, fmt.Errorf("failed to generate secp256k1 private key: %v", err) + } + btcAddress, err := btcutil.NewAddressPubKey(privKey.PubKey().SerializeCompressed(), + btcChainParams) + if err != nil { + return nil, fmt.Errorf("failed to create BTC address from public key: %v", + err) + } + hash := btcAddress.AddressPubKeyHash().String() + ethAddress := ethereum.AddressFromPrivateKey(privKey) + + return map[string]any{ + "ethereumAddress": ethAddress.String(), + "network": netNormalized, + "privateKey": hex.EncodeToString(privKey.Serialize()), + "publicKey": hex.EncodeToString(privKey.PubKey().SerializeCompressed()), + "publicKeyHash": hash, + }, nil +} + +func runPopMiner(this js.Value, args []js.Value) (any, error) { + log.Tracef("runPopMiner") + defer log.Tracef("runPopMiner exit") + + globalMtx.Lock() + if pm != nil { + globalMtx.Unlock() + return map[string]any{"error": "pop miner already running"}, nil + } + + // Don't love doing this in mutex but other options are also costly + pm = &PopMiner{} + pm.ctx, pm.cancel = context.WithCancel(context.Background()) + cfg := popm.NewDefaultConfig() + cfg.BTCChainName = args[0].Get("network").String() + cfg.BTCPrivateKey = args[0].Get("privateKey").String() + cfg.LogLevel = args[0].Get("logLevel").String() // "popm=TRACE:protocol=TRACE" + if cfg.LogLevel == "" { + cfg.LogLevel = "popm=INFO" + } + loggo.ConfigureLoggers(cfg.LogLevel) + + switch cfg.BTCChainName { + case "testnet", "testnet3": + cfg.BFGWSURL = "wss://testnet.rpc.hemi.network" + cfg.BTCChainName = "testnet3" + case "devnet": + cfg.BFGWSURL = "wss://devnet.rpc.hemi.network" + cfg.BTCChainName = "testnet3" + case "local": + // XXX this should only be enabled with a link flag + cfg.BFGWSURL = "ws://localhost:8383" + cfg.BTCChainName = "testnet3" + case "mainnet": + cfg.BFGWSURL = "wss://rpc.hemi.network" + default: + return map[string]any{"error": "invalid network for pop miner"}, nil + } + // We hardcode the route here because we do not want to include another + // packge thus growing WASM. + bfgRoute := "/v1/ws/public" + cfg.BFGWSURL += bfgRoute + + var err error + pm.miner, err = popm.NewMiner(cfg) + if err != nil { + globalMtx.Unlock() + return nil, fmt.Errorf("Failed to create POP miner: %v", err) + } + globalMtx.Unlock() + + // launch in background + pm.wg.Add(1) + go func() { + defer pm.wg.Done() + if err := pm.miner.Run(pm.ctx); err != context.Canceled { + globalMtx.Lock() + defer globalMtx.Unlock() + pm.err = err // Theoretically this can logic race unless we unset om + } + }() + + return map[string]any{"error": ""}, nil +} + +func stopPopMiner(this js.Value, args []js.Value) (any, error) { + log.Tracef("stopPopMiner") + defer log.Tracef("stopPopMiner exit") + + globalMtx.Lock() + if pm == nil { + globalMtx.Unlock() + return map[string]any{"error": "pop miner not running"}, nil + } + oldPM := pm + pm = nil + globalMtx.Unlock() + oldPM.cancel() + + oldPM.wg.Wait() + + var exitError string + if oldPM.err != nil { + exitError = oldPM.err.Error() + } + + return map[string]any{ + "error": exitError, + }, nil +} + +func activePopMiner() (*PopMiner, error) { + globalMtx.Lock() + defer globalMtx.Unlock() + if pm == nil { + return nil, fmt.Errorf("pop miner not running") + } + return pm, nil +} + +// toMap converts a bfg response to a map. Errors are also encoded in a map. +func toMap(response any) map[string]any { + jr, err := json.Marshal(response) + if err != nil { + return map[string]any{"error": err.Error()} + } + mr := make(map[string]any, 10) + err = json.Unmarshal(jr, &mr) + if err != nil { + return map[string]any{"error": err.Error()} + } + return mr +} + +func ping(this js.Value, args []js.Value) (any, error) { + log.Tracef("ping") + defer log.Tracef("ping exit") + + activePM, err := activePopMiner() + if err != nil { + return map[string]any{"error": err.Error()}, nil + } + pr, err := activePM.miner.Ping(activePM.ctx, time.Now().Unix()) + if err != nil { + return map[string]any{"error": err.Error()}, nil + } + + return toMap(pr), nil +} + +func l2Keystones(this js.Value, args []js.Value) (any, error) { + log.Tracef("l2Keystones") + defer log.Tracef("l2Keystones exit") + + c := args[0].Get("numL2Keystones").Int() + if c < 0 || c > 10 { + c = 2 + } + count := uint64(c) + + activePM, err := activePopMiner() + if err != nil { + return map[string]any{"error": err.Error()}, nil + } + pr, err := activePM.miner.L2Keystones(activePM.ctx, count) + if err != nil { + return map[string]any{"error": err.Error()}, nil + } + + return toMap(pr), nil +} + +func bitcoinBalance(this js.Value, args []js.Value) (any, error) { + log.Tracef("bitcoinBalance") + defer log.Tracef("bitcoinBalance exit") + + scriptHash := args[0].Get("scriptHash").String() + + activePM, err := activePopMiner() + if err != nil { + return map[string]any{"error": err.Error()}, nil + } + pr, err := activePM.miner.BitcoinBalance(activePM.ctx, scriptHash) + if err != nil { + return map[string]any{"error": err.Error()}, nil + } + + return toMap(pr), nil +} + +func bitcoinInfo(this js.Value, args []js.Value) (any, error) { + log.Tracef("bitcoinInfo") + defer log.Tracef("bitcoinInfo exit") + + activePM, err := activePopMiner() + if err != nil { + return map[string]any{"error": err.Error()}, nil + } + pr, err := activePM.miner.BitcoinInfo(activePM.ctx) + if err != nil { + return map[string]any{"error": err.Error()}, nil + } + + return toMap(pr), nil +} + +func bitcoinUTXOs(this js.Value, args []js.Value) (any, error) { + log.Tracef("bitcoinUTXOs") + defer log.Tracef("bitcoinUTXOs exit") + + scriptHash := args[0].Get("scriptHash").String() + + activePM, err := activePopMiner() + if err != nil { + return map[string]any{"error": err.Error()}, nil + } + pr, err := activePM.miner.BitcoinUTXOs(activePM.ctx, scriptHash) + if err != nil { + return map[string]any{"error": err.Error()}, nil + } + + return toMap(pr), nil +} + +var ( + log = loggo.GetLogger("hemiwasm") + gitVersion = "not set yet" + + // pre connection and control + CWASMPing = "wasmping" // WASM ping + CGenerateKey = "generatekey" // Generate the various key + CRunPopMiner = "runpopminer" // Run pop miner + CStopPopMiner = "stoppopminer" // Stop pop miner + + // post connection + CPing = "ping" // ping + CL2Keystones = "l2Keystones" // Get L2 keystones + CBitcoinBalance = "bitcoinBalance" // get balance + CBitcoinInfo = "bitcoinInfo" // bitcoin information + CBitcoinUTXOs = "bitcoinUtxos" // bitcoin UTXOs + Dispatcher = map[string]Dispatch{ + CWASMPing: { + Call: wasmPing, + Required: []DispatchArgs{ + {Name: "message", Type: js.TypeString}, + }, + }, + CGenerateKey: { + Call: generateKey, + Required: []DispatchArgs{ + {Name: "network", Type: js.TypeString}, + }, + }, + CRunPopMiner: { + Call: runPopMiner, + Required: []DispatchArgs{ + {Name: "logLevel", Type: js.TypeString}, + {Name: "network", Type: js.TypeString}, + {Name: "privateKey", Type: js.TypeString}, + }, + }, + CStopPopMiner: { + Call: stopPopMiner, + Required: []DispatchArgs{{}}, + }, + + // post connection + CPing: { + Call: ping, + Required: []DispatchArgs{ + {Name: "timestamp", Type: js.TypeNumber}, + }, + }, + CL2Keystones: { + Call: l2Keystones, + Required: []DispatchArgs{ + {Name: "numL2Keystones", Type: js.TypeNumber}, + }, + }, + CBitcoinBalance: { + Call: bitcoinBalance, + Required: []DispatchArgs{ + {Name: "scriptHash", Type: js.TypeString}, + }, + }, + CBitcoinInfo: { + Call: bitcoinInfo, + Required: []DispatchArgs{}, + }, + CBitcoinUTXOs: { + Call: bitcoinUTXOs, + Required: []DispatchArgs{ + {Name: "scriptHash", Type: js.TypeString}, + }, + }, + } + + globalMtx sync.Mutex // used to set and unset pm + pm *PopMiner +) + +func init() { + loggo.ConfigureLoggers(logLevel) +} + +func validateArgs(args []js.Value) (Dispatch, error) { + // Verify we received a readable command + var ed Dispatch + if len(args) != 1 { + return ed, fmt.Errorf("1 argument expected, got %v", len(args)) + } + a := args[0] + if a.Type() != js.TypeObject { + return ed, fmt.Errorf("expected an object, got: %v", a.Type()) + } + m := a.Get("method") + if m.Type() != js.TypeString { + return ed, fmt.Errorf("expected a string, got: %v", m.Type()) + } + d, ok := Dispatcher[m.String()] + if !ok { + return ed, fmt.Errorf("method not found: %v", m.String()) + } + + // Verify required args + for k := range d.Required { + name := d.Required[k].Name + typ := d.Required[k].Type + arg := a.Get(name) + if arg.Type() != typ { + return d, fmt.Errorf("invalid type %v: got %v want %v", + name, arg.Type(), typ) + } + } + return d, nil +} + +func execute(this js.Value, args []js.Value) any { + log.Tracef("execute") + defer log.Tracef("execute exit") + + // Setup promise + handler := js.FuncOf(func(this js.Value, handlerArgs []js.Value) any { + resolve := handlerArgs[0] + reject := handlerArgs[1] + + // Run dispatched call asynchronously + go func() { + // This function must always complete a promise. + var err error + defer func() { + if r := recover(); r != nil { + p := fmt.Sprintf("recovered panic: %v\n%v", + r, string(debug.Stack())) + log.Criticalf(p) + reject.Invoke(js.Global().Get("Error").New(p)) + } else if err != nil { + reject.Invoke(js.Global().Get("Error").New(err.Error())) + } + }() + + // verify args + var d Dispatch + d, err = validateArgs(args) + if err != nil { + return + } + + // dispatch sanitized call + var rv any + rv, err = d.Call(this, args) + if err != nil { + return + } + + // encode response + var j []byte + j, err = json.Marshal(rv) + if err != nil { + return + } + resolve.Invoke(string(j)) + }() + + // The handler of a Promise doesn't return any value + return nil + }) + // Create and return the Promise object + return js.Global().Get("Promise").New(handler) +} + +func dispatch(this js.Value, args []js.Value) any { + defer func() { + if r := recover(); r != nil { + log.Criticalf("recovered panic: %v", r) + log.Criticalf("%v", string(debug.Stack())) + } + }() + + log.Tracef("dispatch") + defer log.Tracef("dispatch exit") + + rv := execute(this, args) + if err, ok := rv.(error); ok && err != nil { + return js.Global().Get("Error").New(err.Error()) + } + return rv +} + +func main() { + log.Tracef("main") + defer log.Tracef("main exit") + + // Enable function dispatcher + log.Infof("=== Start of Day ===") + // Don't use monitorclient.Runtime here because gitVersion is linked in. + log.Infof("%v version %v compiled with go version %v %v/%v revision %v", + filepath.Base(os.Args[0]), version, runtime.Version(), + runtime.GOOS, runtime.GOARCH, gitVersion) + log.Infof("Logging level : %v", logLevel) + + js.Global().Set("dispatch", js.FuncOf(dispatch)) + + <-make(chan bool) // prevents the program from exiting +} diff --git a/web/www/index.html b/web/www/index.html new file mode 100644 index 00000000..fef76813 --- /dev/null +++ b/web/www/index.html @@ -0,0 +1,91 @@ + + + + PoP miner integration tests + + + + + + +

+ + +

+ +

+ + +
+ + + +

+ +

+ + +
+ + + +
+ + + +
+ + + +

+ +

+ + +

+ + +
+
The following commands require a live connection (after RunPopMinerButton)
+
+ +

+ + +

+ +

+ + +
+ + + +

+ +

+ + +
+ + + +

+ +

+ + +

+ +

+ + +
+ + + +

+ + + + diff --git a/web/www/index.js b/web/www/index.js new file mode 100644 index 00000000..396eb8d0 --- /dev/null +++ b/web/www/index.js @@ -0,0 +1,180 @@ +// wasm ping +const WASMPingShow = document.querySelector('.WASMPingShow'); + +async function WASMPing() { + try { + const result = await dispatch({ + method: 'wasmping', + message: 'wasm ping', + }); + WASMPingShow.innerHTML = result; + } catch (err) { + WASMPingShow.innerHTML = err; + console.error('Caught exception', err); + } +} + +WASMPingButton.addEventListener('click', () => { + WASMPing(); +}); + +// generate key +const GenerateKeyShow = document.querySelector('.GenerateKeyShow'); + +async function GenerateKey() { + try { + const result = await dispatch({ + method: 'generatekey', + network: GenerateKeyNetworkInput.value, + }); + GenerateKeyShow.innerHTML = result; + } catch (err) { + GenerateKeyShow.innerHTML = err; + console.error('Caught exception', err); + } +} + +GenerateKeyButton.addEventListener('click', () => { + GenerateKey(); +}); + +// run pop miner +const RunPopMinerShow = document.querySelector('.RunPopMinerShow'); + +async function RunPopMiner() { + try { + const result = await dispatch({ + method: 'runpopminer', + network: RunPopMinerNetworkInput.value, + logLevel: RunPopMinerLogLevelInput.value, + privateKey: RunPopMinerPrivateKeyInput.value, + }); + RunPopMinerShow.innerHTML = result; + } catch (err) { + RunPopMinerShow.innerHTML = err; + console.error('Caught exception', err); + } +} + +RunPopMinerButton.addEventListener('click', () => { + RunPopMiner(); +}); + +// stop pop miner +const StopPopMinerShow = document.querySelector('.StopPopMinerShow'); + +async function StopPopMiner() { + try { + const result = await dispatch({ + method: 'stoppopminer', + }); + StopPopMinerShow.innerHTML = result; + } catch (err) { + StopPopMinerShow.innerHTML = err; + console.error('Caught exception', err); + } +} + +StopPopMinerButton.addEventListener('click', () => { + StopPopMiner(); +}); + +// ping +const PingShow = document.querySelector('.PingShow'); + +async function Ping() { + try { + const result = await dispatch({ + method: 'ping', + timestamp: 0, // XXX pull timestamp + }); + PingShow.innerHTML = result; + } catch (err) { + PingShow.innerHTML = err; + console.error('Caught exception', err); + } +} + +PingButton.addEventListener('click', () => { + Ping(); +}); + +// l2 keystones +const L2KeystonesShow = document.querySelector('.L2KeystonesShow'); + +async function L2Keystones() { + try { + const result = await dispatch({ + method: 'l2Keystones', + numL2Keystones: Number(L2KeystonesNumL2KeystonesInput.value), + }); + L2KeystonesShow.innerHTML = result; + } catch (err) { + L2KeystonesShow.innerHTML = err; + console.error('Caught exception', err); + } +} + +L2KeystonesButton.addEventListener('click', () => { + L2Keystones(); +}); + +// bitcoin balance +const BitcoinBalanceShow = document.querySelector('.BitcoinBalanceShow'); + +async function BitcoinBalance() { + try { + const result = await dispatch({ + method: 'bitcoinBalance', + scriptHash: BitcoinBalanceScriptHashInput.value, + }); + BitcoinBalanceShow.innerHTML = result; + } catch (err) { + BitcoinBalanceShow.innerHTML = err; + console.error('Caught exception', err); + } +} + +BitcoinBalanceButton.addEventListener('click', () => { + BitcoinBalance(); +}); + +// bitcoin info +const BitcoinInfoShow = document.querySelector('.BitcoinInfoShow'); + +async function BitcoinInfo() { + try { + const result = await dispatch({ + method: 'bitcoinInfo', + }); + BitcoinInfoShow.innerHTML = result; + } catch (err) { + BitcoinInfoShow.innerHTML = err; + console.error('Caught exception', err); + } +} + +BitcoinInfoButton.addEventListener('click', () => { + BitcoinInfo(); +}); + +// bitcoin utxos +const BitcoinUTXOsShow = document.querySelector('.BitcoinUTXOsShow'); + +async function BitcoinUTXOs() { + try { + const result = await dispatch({ + method: 'bitcoinUtxos', + scriptHash: BitcoinUTXOsScriptHashInput.value, + }); + BitcoinUTXOsShow.innerHTML = result; + } catch (err) { + BitcoinUTXOsShow.innerHTML = err; + console.error('Caught exception', err); + } +} + +BitcoinUTXOsButton.addEventListener('click', () => { + BitcoinUTXOs(); +}); + diff --git a/web/www/popminer.js b/web/www/popminer.js new file mode 100644 index 00000000..85ae0ac1 --- /dev/null +++ b/web/www/popminer.js @@ -0,0 +1,22 @@ +// Launch go runtime +if (!WebAssembly.instantiateStreaming) { // polyfill + WebAssembly.instantiateStreaming = async (resp, importObject) => { + const source = await (await resp).arrayBuffer(); + return await WebAssembly.instantiate(source, importObject); + }; +} + +const go = new Go(); +let mod, inst; +WebAssembly.instantiateStreaming(fetch("popminer.wasm"), go.importObject).then((result) => { + mod = result.module; + inst = result.instance; + + // Always launch go runtime + go.run(inst); +}).catch((err) => { + // XXX restart wasm instead + console.error(err); +}); + +console.error("hi there");